feat: Discogs music scraper plugin (#87)#100
Conversation
New plugin that integrates with Discogs API for music metadata: - Search by barcode (EAN) or title+artist - Maps: Artist → Author, Label → Publisher, Tracklist → Description - Covers from Discogs images - Format detection: CD, Vinyl, Cassette - Optional API token for higher rate limits (60 vs 25 req/min) - Same hook pattern as OpenLibrary (scrape.sources/fetch/modify)
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds a Discogs-backed multi-source music scraper plugin, introduces a canonical Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant User as Admin/User
participant App as Application
participant Discogs as Discogs API
participant MB as MusicBrainz API
participant CAA as Cover Art Archive
participant Deezer as Deezer API
participant DB as Database
User->>App: Import/scrape request (barcode/title) or admin import
App->>Discogs: Search release by barcode
Discogs-->>App: Release list / none
alt Discogs found
App->>Discogs: Fetch release details
Discogs-->>App: Release metadata + images
else Discogs not found
App->>MB: Search release by barcode
MB-->>App: Release metadata
App->>CAA: Fetch cover by MB release id
CAA-->>App: cover image or none
end
App->>App: Map/normalize payload (tipo_media, tracks, cover, ids)
alt Missing cover or genres
App->>Deezer: Search album by artist+title
Deezer-->>App: cover_xl
end
App->>DB: Merge/persist merged data (including tipo_media)
DB-->>App: OK
App-->>User: Return populated form / saved record
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
storage/plugins/discogs/DiscogsPlugin.php (1)
85-89: Keep hook registration in one source of truth.
setPluginId()immediately callsensureHooksRegistered(), while the same hook names and priorities already live instorage/plugins/discogs/plugin.json. That leaves hook metadata duplicated in two places, and theCOUNT(*) > 0check is too weak to catch partial drift. Prefer manifest-driven registration, or validate the expected hook set explicitly.Also applies to: 94-155
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@storage/plugins/discogs/DiscogsPlugin.php` around lines 85 - 89, The call to ensureHooksRegistered() from setPluginId() duplicates hook metadata that already lives in storage/plugins/discogs/plugin.json; remove the implicit registration here and instead drive hook registration from the manifest (plugin.json) or add an explicit validation that compares the manifest's expected hook names/priorities with what's persisted and only registers missing/incorrect entries. Concretely, stop calling ensureHooksRegistered() inside setPluginId(), implement a single manifest-driven routine (e.g., load and parse storage/plugins/discogs/plugin.json) to register hooks (or to diff and fix persisted hooks), and/or update ensureHooksRegistered()/the registration functions referenced in the block around lines 94-155 to accept the manifest as the source of truth and perform exact set comparison (not COUNT(*) > 0) before inserting/updating hooks.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@storage/plugins/discogs/DiscogsPlugin.php`:
- Around line 225-259: Centralize and implement token-aware throttling inside
the DiscogsPlugin::apiRequest method so callers like the search/fetch sequence
in the try block (calls to apiRequest from within DiscogsPlugin where
getSetting('api_token') is used) no longer have to sleep manually; detect
whether a token is present (token truthy from getSetting or passed into
apiRequest) and enforce a minimum inter-request delay of ~1.0s for authenticated
requests and ~2.4s for anonymous requests, tracking the timestamp of the last
request per-plugin (or per-token/anonymous bucket) to sleep the remaining time
before performing the HTTP call, and remove/avoid hardcoded usleep calls (e.g.,
the usleep(1000000) calls around search and release fetch) so rate limiting is
handled centrally in apiRequest.
- Around line 232-245: The fallback currently does a generic q= search with only
$isbn and then trusts $searchResult['results'][0] which can return an unrelated
release; update the logic in the DiscogsPlugin method that calls
$this->apiRequest so that after the search you verify the candidate release
actually contains the barcode/isbn before accepting it (e.g., inspect the
returned release's identifiers/barcodes fields for a match to $isbn), and if no
exact barcode is found return $currentResult instead of using results[0];
alternatively, remove the generic q= fallback and immediately return
$currentResult when the barcode lookup fails.
---
Nitpick comments:
In `@storage/plugins/discogs/DiscogsPlugin.php`:
- Around line 85-89: The call to ensureHooksRegistered() from setPluginId()
duplicates hook metadata that already lives in
storage/plugins/discogs/plugin.json; remove the implicit registration here and
instead drive hook registration from the manifest (plugin.json) or add an
explicit validation that compares the manifest's expected hook names/priorities
with what's persisted and only registers missing/incorrect entries. Concretely,
stop calling ensureHooksRegistered() inside setPluginId(), implement a single
manifest-driven routine (e.g., load and parse
storage/plugins/discogs/plugin.json) to register hooks (or to diff and fix
persisted hooks), and/or update ensureHooksRegistered()/the registration
functions referenced in the block around lines 94-155 to accept the manifest as
the source of truth and perform exact set comparison (not COUNT(*) > 0) before
inserting/updating hooks.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: b53824a1-80c6-433c-8656-8439478dee1d
📒 Files selected for processing (5)
.gitignorestorage/plugins/discogs/DiscogsPlugin.phpstorage/plugins/discogs/README.mdstorage/plugins/discogs/plugin.jsonstorage/plugins/discogs/wrapper.php
- MediaLabels helper: dynamic field labels based on format (Artista, Etichetta, Tracce for music media) - Frontend book-detail: labels adapt for CD/vinyl/cassette - Admin scheda_libro: labels adapt for music - Discogs plugin settings page with API token input - Rate limiting centralized in apiRequest() (1s auth, 2.5s anon) - Removed unreliable generic q= search fallback - Translations: IT/EN/DE for all music labels
- Add discogs to BUNDLED_PLUGINS in PluginManager and Updater - 7 E2E tests: plugin install/activate, settings page, MediaLabels for music vs regular books, barcode label on frontend, scraping
There was a problem hiding this comment.
Actionable comments posted: 8
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
app/Views/frontend/book-detail.php (1)
1699-1717:⚠️ Potential issue | 🟡 MinorMusic imports still fall through to the
EANlabel.
storage/plugins/discogs/DiscogsPlugin.phpstores the scanned code ineanand leavesisbn13empty. This change only renames theisbn13row, so real Discogs items still hit the unchangedEANblock below and never show the new "Barcode" label. Prefer theeanvalue/label when$isMusicis true.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Views/frontend/book-detail.php` around lines 1699 - 1717, The music imports put the scanned code in $book['ean'] so adjust the ISBN/EAN rendering to prefer EAN for music: update the block that currently uses $book['isbn13'] (and the label logic using $isMusic) to display $book['ean'] when $isMusic and $book['ean'] is not empty (fall back to $book['isbn13'] otherwise), and change the EAN meta-item block to either skip for music or show the label "Barcode" when $isMusic (and use $book['ean'] as the value); target the conditional blocks using $isMusic, $book['isbn13'] and $book['ean'] in the book-detail.php snippet so Discogs-imported items display the Barcode label and value correctly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/Support/MediaLabels.php`:
- Around line 14-29: isMusic currently does substring matching against
self::$musicFormats (including 'audio'), so strings like
"audiolibro"/"audiobook" match; change isMusic to only accept exact normalized
tokens or word-boundary matches: in the isMusic(?string $formato) method
normalize (trim, strtolower), then either split on non-alphanumeric characters
and check each resulting token for exact membership in self::$musicFormats or
use a single preg_match that checks
\b(music|cd|vinyl|lp|cassette|cd_audio|vinile|cassetta|musik)\b; also remove or
avoid plain substring checks for 'audio' (make it a separate token if you keep
it) so audiobook strings no longer match.
In `@app/Views/frontend/book-detail.php`:
- Around line 2178-2180: The fallback label uses the parent page's $isMusic;
change the ternary to use the related item's format flag so each related card
decides its own label—e.g. when rendering <?=
htmlspecialchars($related['autori'] ?? __($isMusic ? 'Artista sconosciuto' :
'Autore sconosciuto')...) ?> replace $isMusic with ($related['isMusic'] ??
$isMusic) (or the related item's format key if named differently) so the
translation call uses the related item's isMusic flag while keeping
htmlspecialchars, ENT_QUOTES and UTF-8 intact.
In `@storage/plugins/discogs/DiscogsPlugin.php`:
- Around line 220-255: The early-return guard that skips Discogs when any
non-Discogs source set a title prevents Discogs-only fields from ever being
merged; locate the conditional starting with is_array($currentResult) &&
!empty($currentResult['title']) and either remove that short-circuit or change
it to check a real completeness signal (e.g. a boolean flag like
$currentResult['complete'] === true or a specific required-field check) so that
mergeBookData($currentResult, $discogsData) and BookDataMerger::merge() still
run for partial upstream results; ensure mapReleaseToPinakes($release,
$firstResult, $isbn) output is merged rather than skipped.
- Around line 604-648: getSetting()/saveSettings() in DiscogsPlugin currently
read/write plaintext from plugin_settings while
PluginController::updateSettings() uses PluginManager::setSetting() (which
stores encrypted "ENC:..." values), causing inconsistent handling of api_token;
update DiscogsPlugin to always use PluginManager's API for settings so
encryption/decryption is consistent: replace direct DB reads/writes in
getSetting() and saveSettings() to call
PluginManager::getSetting()/PluginManager::setSetting() (or the project
equivalent), ensure the resolved pluginId path still works when fetching via
PluginManager, and make sure the token passed to the Discogs client is the
decrypted value returned by PluginManager::getSetting() (i.e., strip/handle
"ENC:" transparently via the manager).
In `@storage/plugins/discogs/views/settings.php`:
- Around line 96-100: The form currently round-trips the masked value from
DiscogsPlugin::getSettings() through the password input (id/name "api_token"),
which causes literal asterisks to overwrite the real token on save; change the
view in settings.php to render the password input value as empty when a token
exists and instead render a separate indicator/hidden field (e.g., "has_token")
to show presence of a saved token, and update the server-side save handler
(e.g., DiscogsPlugin::saveSettings() or equivalent) to only update the stored
token when a non-empty "api_token" is submitted (ignore empty values and/or the
"has_token" flag), so opening and saving without editing does not replace the
real token.
- Around line 133-137: Replace the hardcoded href that calls
url('/admin/plugins') with the named-route helper (e.g., route_path('plugins')
or RouteTranslator::route('plugins')) and pass that result through
htmlspecialchars(…, ENT_QUOTES, 'UTF-8') before output; update the anchor in
settings.php so it uses the named route helper instead of the literal
"/admin/plugins" to keep routing and localization consistent (refer to
route_path and RouteTranslator::route as the functions to use).
In `@tests/discogs-plugin.spec.js`:
- Around line 183-202: The test currently fetches the admin URL
(`/admin/libri/${bookId}`) so it never exercises the frontend view or the
EAN→Barcode label logic in app/Views/frontend/book-detail.php; update the test
'6. Frontend: music book shows Barcode instead of ISBN-13' to request the public
book page (use the public route the app serves for book details instead of
`/admin/libri/${bookId}`, e.g. the frontend path built from BASE and the book
identifier/slug), then assert that the returned HTML for that public page
contains "Barcode" (or the music-aware labels) and not the ISBN label; keep the
same test setup (inserting the record with TEST_BARCODE and selecting bookId)
but replace the admin fetch using page.request.get with a fetch to the frontend
URL so the EAN→Barcode behavior in app/Views/frontend/book-detail.php is
actually validated.
- Around line 77-137: The test uses broad page-wide selectors so operations may
act on other plugins and silent no-ops hide failures; scope all locators to the
Discogs card (discogsCard) before clicking install (installBtn), activate
(activateBtn) or opening settings (settingsLink), assert discogsCard.isVisible()
and throw/fail if missing, assert that dbQuery shows activation
(pluginActivated) after activateBtn.click(), and explicitly assert
tokenField.isVisible() (instead of silently continuing) so the test fails when
Discogs is not installed/activated or the API token field is absent.
---
Outside diff comments:
In `@app/Views/frontend/book-detail.php`:
- Around line 1699-1717: The music imports put the scanned code in $book['ean']
so adjust the ISBN/EAN rendering to prefer EAN for music: update the block that
currently uses $book['isbn13'] (and the label logic using $isMusic) to display
$book['ean'] when $isMusic and $book['ean'] is not empty (fall back to
$book['isbn13'] otherwise), and change the EAN meta-item block to either skip
for music or show the label "Barcode" when $isMusic (and use $book['ean'] as the
value); target the conditional blocks using $isMusic, $book['isbn13'] and
$book['ean'] in the book-detail.php snippet so Discogs-imported items display
the Barcode label and value correctly.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: f1ff7bca-6875-4950-9817-c1b642b81b57
📒 Files selected for processing (12)
.gitignoreapp/Controllers/PluginController.phpapp/Support/MediaLabels.phpapp/Support/PluginManager.phpapp/Support/Updater.phpapp/Views/frontend/book-detail.phpapp/Views/libri/scheda_libro.phplocale/de_DE.jsonlocale/en_US.jsonstorage/plugins/discogs/DiscogsPlugin.phpstorage/plugins/discogs/views/settings.phptests/discogs-plugin.spec.js
✅ Files skipped from review due to trivial changes (5)
- .gitignore
- app/Support/Updater.php
- app/Support/PluginManager.php
- locale/de_DE.json
- locale/en_US.json
ScrapeController::normalizeIsbnFields() was auto-populating isbn13 from the search term. For Discogs results (source=discogs), the search term is a barcode/EAN, not an ISBN — skip ISBN auto-population.
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (2)
tests/discogs-import.spec.js (2)
109-112: The artist check is currently a no-op.This block computes
authorCountbut never asserts it, so the suite does not actually verify theArtists → Autoremapping from this PR.Suggested fix
- const authorItems = page.locator('#autori-wrapper .choices__item--selectable, .choices__item.choices__item--selectable'); + const authorItems = page.locator('#autori-wrapper .choices__item.choices__item--selectable'); const authorCount = await authorItems.count().catch(() => 0); + if (authorCount === 0) { + throw new Error('No artist chips were populated by the Discogs import'); + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/discogs-import.spec.js` around lines 109 - 112, The test computes authorCount from the locator authorItems but never asserts it; update the test (in tests/discogs-import.spec.js) to assert that authorCount is greater than zero (e.g., use your test framework's expect/assert on authorCount) so the "Artists → Autore" mapping is actually verified; refer to the authorItems locator and the authorCount variable when adding the assertion.
179-184: Keep the music-format guard aligned with production.
app/Support/MediaLabels.php:14-33treatslp,cassetta,cassette,audio,musik, andmusicas music too. This subset can skip the label assertion even when the saved item is a valid music import.Suggested fix
- if (formato && ['cd_audio', 'vinile', 'cd', 'vinyl'].some(f => formato.toLowerCase().includes(f))) { + if (formato && ['cd_audio', 'vinile', 'cassetta', 'vinyl', 'lp', 'cd', 'cassette', 'audio', 'musik', 'music'] + .some(f => formato.toLowerCase().includes(f))) {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/discogs-import.spec.js` around lines 179 - 184, The music-format guard in the test only checks ['cd_audio','vinile','cd','vinyl'] but must match the production list used by MediaLabels (include tokens like 'lp', 'cassetta', 'cassette', 'audio', 'musik', 'music' as well); update the array used with formato.toLowerCase().includes(...) in the test to include those additional tokens so the conditional around hasMusicLabel (which checks content for 'Etichetta'/'Label'/'Anno di Uscita'/'Release Year') mirrors the production MediaLabels behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@tests/discogs-import.spec.js`:
- Around line 77-79: The test currently treats a missing import button or empty
titolo as a skip (using importBtn.isVisible(...).catch(() => false) then
test.skip), but after plugin activation those cases indicate a real product
failure because fetchFromDiscogs() can return previous results on errors; update
the test to fail fast instead: replace the test.skip branches at the import
button check (importBtn.isVisible) and the titolo empty check with explicit
assertions that throw or use test.fail/assert to fail the test when the import
button is not visible or titolo is empty, and reference the fetchFromDiscogs()
behavior in the failure message to aid debugging.
- Around line 50-53: The test teardown in test.afterAll currently only removes
rows by EAN via dbExec("DELETE FROM libri WHERE ean = '0720642442524'...") but
the import logic also persists ISBN-13 (see save paths around lines saving
isbn13), so modify the cleanup to delete rows where either ean = '0720642442524'
OR isbn13 = '0720642442524' (or run two deletes) using the same dbExec call(s);
update the test.afterAll block to remove both persistence paths so stale rows
don't survive between test runs.
- Around line 151-160: The test currently uses test.skip when dbQuery returns no
row, which hides a failed persistence; update the block that checks book and
bookByIsbn so that a missing DB row fails the test instead of skipping: replace
the test.skip(bookByIsbn === '', ...) call with an assertion or explicit failure
(e.g., expect(bookByIsbn).not.toBe('') or throw new Error(...)) and keep the
subsequent expect(bookByIsbn.toLowerCase()).toContain('nevermind') path; locate
the logic around the book variable, the dbQuery(...) call and the test.skip
invocation to make this change.
---
Nitpick comments:
In `@tests/discogs-import.spec.js`:
- Around line 109-112: The test computes authorCount from the locator
authorItems but never asserts it; update the test (in
tests/discogs-import.spec.js) to assert that authorCount is greater than zero
(e.g., use your test framework's expect/assert on authorCount) so the "Artists →
Autore" mapping is actually verified; refer to the authorItems locator and the
authorCount variable when adding the assertion.
- Around line 179-184: The music-format guard in the test only checks
['cd_audio','vinile','cd','vinyl'] but must match the production list used by
MediaLabels (include tokens like 'lp', 'cassetta', 'cassette', 'audio', 'musik',
'music' as well); update the array used with formato.toLowerCase().includes(...)
in the test to include those additional tokens so the conditional around
hasMusicLabel (which checks content for 'Etichetta'/'Label'/'Anno di
Uscita'/'Release Year') mirrors the production MediaLabels behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 9563aa2a-a47d-44d2-8396-94e770ceb55c
📒 Files selected for processing (1)
tests/discogs-import.spec.js
The book form submit shows a SweetAlert 'Are you sure?' dialog. The test now clicks the confirm button before waiting for navigation. All 6 import tests pass including save + DB verify + label check.
- MediaLabels::formatDisplayName() maps cd_audio→"CD Audio", vinile→"Vinile" etc. - All format names translatable via __() (IT/EN/DE) - Frontend book-detail: format shows display name, not raw key - Admin scheda_libro: same format display name - Discogs plugin: tracklist now generates <ol> HTML, not plain text - Frontend: music description section shows formatted tracklist with <ol> - MediaLabels::formatTracklist() for legacy plain-text tracklists
There was a problem hiding this comment.
Actionable comments posted: 3
♻️ Duplicate comments (1)
app/Support/MediaLabels.php (1)
14-29:⚠️ Potential issue | 🟠 MajorStop using substring matches for music detection.
Line 28 still matches short fragments like
audio,cd, andlp, soaudiolibro/audiobookand other unrelated formats can flip the UI to music labels. Normalize to a canonical token and do exact matches or word-boundary checks instead.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Support/MediaLabels.php` around lines 14 - 29, The isMusic method in MediaLabels currently uses substring checks against self::$musicFormats which causes false positives (e.g., "audiolibro"); update it to normalize the input (trim, lowercase) then split/tokenize on non-word boundaries into canonical tokens and check for exact token membership instead of str_contains; also normalize/replace entries in the $musicFormats list to canonical tokens (e.g., "cd", "vinyl", "cassette", "lp", "music", "audio") and perform either an in_array lookup or a word-boundary regex match against those tokens in isMusic to avoid matching substrings like "audiobook".
🧹 Nitpick comments (6)
app/Views/libri/scheda_libro.php (2)
8-9: Unused variable$isMusic.The variable is declared but not referenced in the changed code. The label switching is done inline by passing
$libro['formato']directly toMediaLabels::label()calls. Consider removing if not used elsewhere in this file.#!/bin/bash # Verify if $isMusic is used anywhere in the file rg '\$isMusic' app/Views/libri/scheda_libro.php🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Views/libri/scheda_libro.php` around lines 8 - 9, Remove the unused local variable $isMusic which is assigned via MediaLabels::isMusic($libro['formato'] ?? null) but never referenced; instead, delete the assignment line and rely on the existing inline MediaLabels::label(...) calls (or if you intended to use $isMusic, replace the inline calls to reference $isMusic where appropriate). Ensure you remove only the unused $isMusic declaration and do not alter MediaLabels::label or other logic.
147-147: Consider escapingMediaLabels::label()output for consistency.Line 379 properly escapes the format display name with
HtmlHelper::e(), but lines 147, 337, and 366 output labels directly. While these are likely safe translation strings, escaping ensures consistent defense-in-depth.🔧 Suggested fix for consistent escaping
- <span class="font-medium"><?= \App\Support\MediaLabels::label('editore', $libro['formato'] ?? null) ?>:</span> + <span class="font-medium"><?= App\Support\HtmlHelper::e(\App\Support\MediaLabels::label('editore', $libro['formato'] ?? null)) ?>:</span>Apply similar changes to lines 337 and 366.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Views/libri/scheda_libro.php` at line 147, Escape the output of MediaLabels::label() for defense-in-depth: replace direct echoing of \App\Support\MediaLabels::label(...) with HtmlHelper::e(\App\Support\MediaLabels::label(...)) wherever it is used (e.g., the occurrences that currently render labels), and apply the same change to the other two label usages so all label outputs are consistently wrapped with HtmlHelper::e().tests/discogs-import.spec.js (4)
63-63: Prefer condition-based waits over fixed timeouts.
waitForTimeout(2000)is brittle and can cause flaky tests or unnecessary slowdowns. Consider waiting for a specific DOM condition or network idle state instead.Suggested fix
- await page.waitForTimeout(2000); + // Wait for plugin registration to complete + await page.waitForFunction(() => { + return document.querySelector('[data-plugin="discogs"]') !== null || + document.readyState === 'complete'; + }, { timeout: 5000 }).catch(() => {});🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/discogs-import.spec.js` at line 63, Replace the brittle fixed delay at page.waitForTimeout(2000) with a condition-based wait: identify a stable DOM element or network condition that signals the app is ready (e.g., a specific selector or an API response) and use page.waitForSelector('...') or page.waitForResponse(...) / page.waitForLoadState('networkidle') instead of page.waitForTimeout; update the test in tests/discogs-import.spec.js where page.waitForTimeout(2000) is called so it waits for the specific DOM selector or response relevant to the import flow.
110-112: Unnecessary.catch(() => 0)oncount().Playwright's
count()returns aPromise<number>that resolves to 0 for no matches—it doesn't reject. The catch masks unexpected errors.Suggested fix
- const authorCount = await authorItems.count().catch(() => 0); + const authorCount = await authorItems.count();🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/discogs-import.spec.js` around lines 110 - 112, The call to authorItems.count() is wrapped with an unnecessary .catch(() => 0) which masks real errors; remove the .catch and simply await authorItems.count() (locator: authorItems from page.locator('#autori-wrapper .choices__item--selectable, .choices__item.choices__item--selectable')) so you receive the actual numeric count (Playwright resolves to 0 when no matches) and let unexpected errors surface normally.
117-122: Complex OR assertion is hard to debug on failure.When this fails, the error message won't indicate which field was checked or what values were found.
Suggested fix for clearer failure messages
// Check EAN field has the barcode const eanValue = await page.locator('input[name="ean"]').inputValue(); // The barcode might be in isbn13 or ean depending on the scraper const isbn13Value = await page.locator('input[name="isbn13"]').inputValue(); - expect(eanValue === TEST_BARCODE || isbn13Value === TEST_BARCODE || - eanValue.includes('720642442524') || isbn13Value.includes('720642442524')).toBe(true); + const barcodeFound = eanValue === TEST_BARCODE || isbn13Value === TEST_BARCODE || + eanValue.includes('720642442524') || isbn13Value.includes('720642442524'); + expect(barcodeFound, `Expected barcode in ean="${eanValue}" or isbn13="${isbn13Value}"`).toBe(true);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/discogs-import.spec.js` around lines 117 - 122, The complex OR assertion using eanValue, isbn13Value, and TEST_BARCODE is hard to debug; replace it with clearer checks: first compute eanValue and isbn13Value as already done, then assert each possibility in separate expectations (or a single expectation that includes a diagnostic message) so test failure shows which field/value failed; reference the eanValue and isbn13Value variables and the TEST_BARCODE constant in tests/discogs-import.spec.js (replace the combined expect(...) with individual expects that check eanValue === TEST_BARCODE, isbn13Value === TEST_BARCODE, eanValue.includes('720642442524'), and isbn13Value.includes('720642442524') or a single expect with a descriptive failure message containing the actual values).
86-88: Replace fixed 8-second timeout with a condition-based wait.Fixed timeouts are brittle—too short causes flakiness, too long wastes CI time. Wait for the scrape result indicator instead.
Suggested fix
// Wait for scraping response (Discogs needs time + rate limits) - // The scraping service tries multiple sources — wait up to 20s - await page.waitForTimeout(8000); + // Wait for title field to be populated or loading indicator to disappear + await page.waitForFunction( + () => { + const title = document.querySelector('input[name="titolo"]'); + const loading = document.querySelector('.scrape-loading, .spinner'); + return (title && title.value !== '') || !loading; + }, + { timeout: 20000 } + ).catch(() => {});🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/discogs-import.spec.js` around lines 86 - 88, Replace the brittle fixed delay (page.waitForTimeout) with a condition-based wait that polls for the scraping result indicator: remove the await page.waitForTimeout(8000) call and instead use page.waitForSelector(...) or page.waitForFunction(...) to wait up to the same maximum (e.g., 20s) for the element/text that signals the scrape completed (the DOM selector or text shown by the scraping service), and keep the existing test flow after that; this change touches the page.waitForTimeout usage in the test and uses page.waitForSelector/page.waitForFunction to await the scrape result.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/Support/MediaLabels.php`:
- Around line 90-91: The helper returns raw external tracklist text ($text) when
($tracks === false || count($tracks) < 2) and in the similar branch at lines
104-105; instead of returning $text raw, HTML-escape/encode it (e.g., via the
framework's escape helper or htmlspecialchars) before returning so the helper
consistently returns safe HTML fragments; update the branches that return $text
to return the escaped version and keep any intentional HTML whitelisting as a
separate, explicit path.
In `@locale/de_DE.json`:
- Around line 3517-3527: The German locale adds overly specific and a duplicate
media label: change the value for the "Cartaceo" key from "Taschenbuch" to a
more generic term like "Druckausgabe" (or "Print") so
MediaLabels::formatDisplayName() doesn't force a paperback meaning, and remove
or consolidate the duplicate "Altro" key (the second "Altro": "Sonstiges" entry)
so there is only one "Altro" translation in the file.
In `@tests/discogs-import.spec.js`:
- Line 28: The test suite runs files in parallel despite test.describe.serial in
tests/discogs-import.spec.js; to avoid cross-file DB race conditions set
Playwright workers to 1: update tests/playwright.config.js to add or set
workers: 1 in the exported config object, or alter the test NPM script in
package.json to append the flag --workers=1 so all test files run with a single
worker; ensure you apply the change globally so tests like
discogs-import.spec.js, discogs-plugin.spec.js, admin-features.spec.js, and
email-notifications.spec.js do not execute concurrently.
---
Duplicate comments:
In `@app/Support/MediaLabels.php`:
- Around line 14-29: The isMusic method in MediaLabels currently uses substring
checks against self::$musicFormats which causes false positives (e.g.,
"audiolibro"); update it to normalize the input (trim, lowercase) then
split/tokenize on non-word boundaries into canonical tokens and check for exact
token membership instead of str_contains; also normalize/replace entries in the
$musicFormats list to canonical tokens (e.g., "cd", "vinyl", "cassette", "lp",
"music", "audio") and perform either an in_array lookup or a word-boundary regex
match against those tokens in isMusic to avoid matching substrings like
"audiobook".
---
Nitpick comments:
In `@app/Views/libri/scheda_libro.php`:
- Around line 8-9: Remove the unused local variable $isMusic which is assigned
via MediaLabels::isMusic($libro['formato'] ?? null) but never referenced;
instead, delete the assignment line and rely on the existing inline
MediaLabels::label(...) calls (or if you intended to use $isMusic, replace the
inline calls to reference $isMusic where appropriate). Ensure you remove only
the unused $isMusic declaration and do not alter MediaLabels::label or other
logic.
- Line 147: Escape the output of MediaLabels::label() for defense-in-depth:
replace direct echoing of \App\Support\MediaLabels::label(...) with
HtmlHelper::e(\App\Support\MediaLabels::label(...)) wherever it is used (e.g.,
the occurrences that currently render labels), and apply the same change to the
other two label usages so all label outputs are consistently wrapped with
HtmlHelper::e().
In `@tests/discogs-import.spec.js`:
- Line 63: Replace the brittle fixed delay at page.waitForTimeout(2000) with a
condition-based wait: identify a stable DOM element or network condition that
signals the app is ready (e.g., a specific selector or an API response) and use
page.waitForSelector('...') or page.waitForResponse(...) /
page.waitForLoadState('networkidle') instead of page.waitForTimeout; update the
test in tests/discogs-import.spec.js where page.waitForTimeout(2000) is called
so it waits for the specific DOM selector or response relevant to the import
flow.
- Around line 110-112: The call to authorItems.count() is wrapped with an
unnecessary .catch(() => 0) which masks real errors; remove the .catch and
simply await authorItems.count() (locator: authorItems from
page.locator('#autori-wrapper .choices__item--selectable,
.choices__item.choices__item--selectable')) so you receive the actual numeric
count (Playwright resolves to 0 when no matches) and let unexpected errors
surface normally.
- Around line 117-122: The complex OR assertion using eanValue, isbn13Value, and
TEST_BARCODE is hard to debug; replace it with clearer checks: first compute
eanValue and isbn13Value as already done, then assert each possibility in
separate expectations (or a single expectation that includes a diagnostic
message) so test failure shows which field/value failed; reference the eanValue
and isbn13Value variables and the TEST_BARCODE constant in
tests/discogs-import.spec.js (replace the combined expect(...) with individual
expects that check eanValue === TEST_BARCODE, isbn13Value === TEST_BARCODE,
eanValue.includes('720642442524'), and isbn13Value.includes('720642442524') or a
single expect with a descriptive failure message containing the actual values).
- Around line 86-88: Replace the brittle fixed delay (page.waitForTimeout) with
a condition-based wait that polls for the scraping result indicator: remove the
await page.waitForTimeout(8000) call and instead use page.waitForSelector(...)
or page.waitForFunction(...) to wait up to the same maximum (e.g., 20s) for the
element/text that signals the scrape completed (the DOM selector or text shown
by the scraping service), and keep the existing test flow after that; this
change touches the page.waitForTimeout usage in the test and uses
page.waitForSelector/page.waitForFunction to await the scrape result.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 52c3815c-fa32-4a11-81f2-e258f66516db
📒 Files selected for processing (8)
app/Controllers/ScrapeController.phpapp/Support/MediaLabels.phpapp/Views/frontend/book-detail.phpapp/Views/libri/scheda_libro.phplocale/de_DE.jsonlocale/en_US.jsonstorage/plugins/discogs/DiscogsPlugin.phptests/discogs-import.spec.js
✅ Files skipped from review due to trivial changes (2)
- app/Controllers/ScrapeController.php
- locale/en_US.json
🚧 Files skipped from review as they are similar to previous changes (1)
- app/Views/frontend/book-detail.php
locale/de_DE.json
Outdated
| "Cartaceo": "Taschenbuch", | ||
| "eBook": "eBook", | ||
| "Audiolibro": "Hörbuch", | ||
| "CD Audio": "Audio-CD", | ||
| "Vinile": "Schallplatte", | ||
| "LP": "LP", | ||
| "Cassetta": "Kassette", | ||
| "DVD": "DVD", | ||
| "Blu-ray": "Blu-ray", | ||
| "Digitale": "Digital", | ||
| "Altro": "Sonstiges", |
There was a problem hiding this comment.
Clean up the new German media labels.
MediaLabels::formatDisplayName() will surface Cartaceo as Taschenbuch, which is too specific for a generic print/physical format, and Line 3527 duplicates the existing Altro key from Line 207. Druckausgabe or Print would be a closer translation here.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@locale/de_DE.json` around lines 3517 - 3527, The German locale adds overly
specific and a duplicate media label: change the value for the "Cartaceo" key
from "Taschenbuch" to a more generic term like "Druckausgabe" (or "Print") so
MediaLabels::formatDisplayName() doesn't force a paperback meaning, and remove
or consolidate the duplicate "Altro" key (the second "Altro": "Sonstiges" entry)
so there is only one "Altro" translation in the file.
Database: - New ENUM column tipo_media (libro/disco/audiolibro/dvd/altro) - Migration auto-populates from existing formato values - Index idx_libri_tipo_media for efficient filtering Backend: - MediaLabels: allTypes(), icon(), schemaOrgType(), inferTipoMedia() - All 7 controllers updated: filter, import/export, scraping - BookRepository: hasColumn guard for backward compat - Discogs plugin auto-sets tipo_media='disco' Admin UI: - Book form: tipo_media dropdown selector - Book list: icon column + filter dropdown - Book detail: media type badge Frontend: - Catalog: tipo_media filter in sidebar - Book detail: Schema.org dynamic type (Book/MusicAlbum/Audiobook/Movie) - Catalog grid: media type badge on non-book cards - Format display names: cd_audio → "CD Audio" (translated) Translations: IT/EN/DE for all media types and labels
There was a problem hiding this comment.
Actionable comments posted: 11
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
app/Controllers/ScrapeController.php (1)
76-99:⚠️ Potential issue | 🟠 MajorDiscogs plugin cannot process non-ISBN barcodes due to validation gate.
The
byIsbn()method validates input withisValidIsbn()(line 89), which accepts only ISBN-10 (10 chars) or ISBN-13 (13 digits) formats with strict checksums. Non-ISBN EAN/UPC codes (e.g., 12-digit EAN) are rejected beforescrape.fetch.customis invoked. Discogs plugin registers onlyscrape.sources,scrape.fetch.custom, andscrape.data.modifyhooks—noscrape.isbn.validatebypass to widen acceptance. No alternative barcode entrypoint exists.The guard at
normalizeIsbnFields()(lines 776–780) preservessource === 'discogs'data, but the plugin never receives non-ISBN barcode inputs due to the validation wall at line 94. Barcode-first imports will fail with a 400 error before reaching the plugin.To enable EAN/UPC support as documented, either: (1) relax
isValidIsbn()to accept EAN formats, (2) add ascrape.isbn.validatehook in Discogs plugin to bypass validation, or (3) create a separate barcode endpoint.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Controllers/ScrapeController.php` around lines 76 - 99, byIsbn() currently rejects non-ISBN barcodes so Discogs never sees EAN/UPC inputs; update the validation path: change isValidIsbn() to also accept common barcode formats (EAN-13, UPC-A 12, EAN-8) and ensure the cleaned value ($cleanIsbn) is normalized (strip non-digits, uppercase) before validation, keeping the existing hook call (\App\Support\Hooks::apply('scrape.isbn.validate', ...)); this lets barcode values pass the validation gate and reach scrape.fetch.custom and normalizeIsbnFields() where source === 'discogs' is preserved.app/Controllers/CsvImportController.php (1)
1229-1254:⚠️ Potential issue | 🟠 MajorGuard the
UPDATE libriwith the soft-delete predicate.
findExistingBook()filters deleted rows, but this standalone update can still hit a record that gets soft-deleted between lookup and write. Add the predicate here so the write path matches the repo contract.As per coding guidelines, `Every query on libri table MUST include soft-delete condition: AND deleted_at IS NULL`.🛡️ Minimal fix
- WHERE id = ? + WHERE id = ? AND deleted_at IS NULL🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Controllers/CsvImportController.php` around lines 1229 - 1254, The UPDATE statement in CsvImportController (the standalone "UPDATE libri SET ... WHERE id = ?") must include the soft-delete predicate to match findExistingBook() and avoid updating a row that was soft-deleted between lookup and write; modify the SQL in the CsvImportController update prepared statement to add "AND deleted_at IS NULL" (so the WHERE clause becomes "WHERE id = ? AND deleted_at IS NULL") so every write to the libri table respects the soft-delete contract.
♻️ Duplicate comments (2)
app/Support/MediaLabels.php (2)
93-95:⚠️ Potential issue | 🔴 CriticalEscape the fallback text before returning HTML.
These branches hand raw external text back from a helper that otherwise emits HTML fragments, so callers can end up echoing untrusted markup whenever the parser does not split the tracklist.
Safer fallback
- if ($tracks === false || count($tracks) < 2) { - return $text; // Not a tracklist, return as-is - } + if ($tracks === false || count($tracks) < 2) { + return nl2br(htmlspecialchars($text, ENT_QUOTES, 'UTF-8'), false); + } @@ - if (empty($items)) { - return $text; - } + if ($items === []) { + return nl2br(htmlspecialchars($text, ENT_QUOTES, 'UTF-8'), false); + }Also applies to: 107-108
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Support/MediaLabels.php` around lines 93 - 95, The helper returns raw external $text when parsing fails (if $tracks === false || count($tracks) < 2 and similarly at lines 107-108), which can emit untrusted markup; change those fallback returns to return an escaped string instead (e.g., replace return $text with return e($text) in Laravel or return htmlspecialchars($text, ENT_QUOTES, 'UTF-8') where e() isn't available) so callers always get safely escaped HTML when $tracks or $text are returned.
14-35:⚠️ Potential issue | 🟠 MajorTighten the music matcher so audiobooks stay out of it.
str_contains($lower, 'audio')makes values likeaudiolibro/audiobookreturntrue, which flips labels and tracklist rendering to music-specific UI for audiobook records.Suggested tightening
- /** `@var` array<int, string> */ - private static array $musicFormats = [ - 'cd_audio', 'vinile', 'cassetta', 'vinyl', 'lp', 'cd', 'cassette', 'audio', 'musik', 'music' - ]; + /** `@var` array<int, string> */ + private const MUSIC_FORMATS = [ + 'cd_audio', + 'vinile', + 'audiocassetta', + 'cassetta', + 'vinyl', + 'lp', + 'cd', + 'cassette', + ]; @@ - foreach (self::$musicFormats as $musicFormat) { - if (str_contains($lower, $musicFormat)) { - return true; - } - } - return false; + if (in_array($lower, self::MUSIC_FORMATS, true)) { + return true; + } + + return preg_match('/\b(?:music|musik)\b/i', $lower) === 1;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Support/MediaLabels.php` around lines 14 - 35, The isMusic method currently treats any occurrence of tokens like "audio" as music (via str_contains), causing "audiolibro"/"audiobook" to be misclassified; update MediaLabels::isMusic to match whole words or token boundaries instead of substring matches: replace the str_contains loop with a regex word-boundary check (e.g., use preg_match with \b and preg_quote for each entry) or remove overly-generic tokens like "audio" from the $musicFormats list and only keep explicit music identifiers; reference the static property MediaLabels::$musicFormats and the method MediaLabels::isMusic when making the change. Ensure null/empty handling remains unchanged and that matching is case-insensitive.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/Controllers/CsvImportController.php`:
- Line 773: When processing CSV columns for tipo_media, don't blindly synthesize
or overwrite on updates: in updateBook() only set tipo_media when the CSV
actually contains a non-null value (e.g., check array_key_exists and that the
value is not null/empty) and normalize that provided value via your media helper
instead of inferring; do not call MediaLabels::inferTipoMedia() in updateBook().
Leave inference with MediaLabels::inferTipoMedia() only in insertBook() when
creating a new record. Apply the same pattern to the other CSV upsert paths
referenced (the other places using 'tipo_media' around the file) so updates
preserve existing tipo_media when the CSV omits the column and inserts still get
a sensible default.
In `@app/Controllers/LibriController.php`:
- Line 652: Remove the hardcoded default that sets 'tipo_media' => 'libro' in
the payload so that scraped_tipo_media can be used by updateOptionals($id,
array_merge($data, $fields)); specifically, stop seeding 'tipo_media' in the
code block that currently inserts 'tipo_media' => 'libro' (and the duplicate
instance around the other occurrence noted) so the merged payload does not
always override scraped_tipo_media — leave tipo_media absent or null unless an
explicit, authoritative tipo_media is provided.
In `@app/Models/BookRepository.php`:
- Around line 646-649: The update path currently normalizes a missing tipo_media
to 'libro', overwriting existing values; change the logic in BookRepository so
tipo_media is only persisted when the caller explicitly provided it (check
array_key_exists('tipo_media', $data) or isset($data['tipo_media']) before
calling $addSet). In other words, keep the existing DB value unless tipo_media
is present in $data, and when present continue using normalizeEnumValue($val,
'tipo_media', 'libro') and $addSet('tipo_media', 's', ...).
In `@app/Support/MediaLabels.php`:
- Around line 41-53: The formatDisplayName mapping and lookup are missing common
token aliases (e.g., 'cd', 'vinyl', 'cassette', 'blu_ray' and other
spelling/underscore/hyphen variants), so update the private static array
$formatDisplayNames to include these alias keys pointing to the same display
values (e.g., add 'cd' => 'CD', 'vinyl' => 'Vinile'/'Vinyl' as appropriate,
'cassette' => 'Cassetta', 'blu_ray' => 'Blu-ray', plus any
space/underscore/hyphen variants), and also ensure formatDisplayName()
normalizes the incoming token (lowercase and normalize
underscores/hyphens/spaces) before lookup; apply the same alias
additions/normalization to the other equivalent mappings referenced in the
comment (the arrays/methods around the other ranges) so all code paths accept
the same tokens.
In `@app/Views/frontend/catalog-grid.php`:
- Around line 41-43: The badge outputs unescaped helper values into HTML
attributes (span title and i class) using
MediaLabels::tipoMediaDisplayName($book['tipo_media']) and
MediaLabels::icon($book['tipo_media']); wrap both outputs with
htmlspecialchars(..., ENT_QUOTES, 'UTF-8') before echoing so translated labels
or icon names with quotes won’t break markup, e.g. escape the title value and
the class string for the <i> element while preserving the existing conditional
on $book['tipo_media'].
In `@app/Views/frontend/catalog.php`:
- Around line 1302-1326: The new tipo_media filter only updates the server but
the client never reapplies active states or label mapping; add a client-side
sync path analogous to syncAvailabilityActiveState() that listens for tipo_media
changes and updates the sidebar links' active class and the summary via
updateActiveFiltersDisplay(), and expose a JS label map built from
MediaLabels::allTypes() (emit the value=>label pairs from PHP into the page) so
updateActiveFiltersDisplay() shows human-friendly labels instead of raw enum
keys; ensure updateFilter('tipo_media', ...) triggers the same AJAX/DOM-refresh
flow as availability so the active state persists after AJAX updates.
- Around line 1318-1321: The inline JS argument currently passes raw PHP
interpolation for '<?= $tmValue ?>' into the onclick that calls
updateFilter('tipo_media', ...); fix by serializing the PHP value with
json_encode using JSON_HEX_TAG so the JS receives a safely escaped string;
update the onclick to call updateFilter('tipo_media', <serialized tmValue>)
where <serialized tmValue> is produced by json_encode($tmValue, JSON_HEX_TAG) to
prevent breaking quotes/HTML and follow the project's escaping guideline.
In `@app/Views/libri/index.php`:
- Around line 688-698: The render currently shows the enum key for tipo_media;
load the localized label map from MediaLabels::allTypes() into JS (e.g., a
mediaLabels object) and use it in the tipo_media column render: replace title
and displayed tooltip text with mediaLabels[data] || data (falling back to the
raw value). Do the same replacement where the active-filter chip is built (the
component that renders the selected tipo_media filter) so both the tooltip and
the chip show the localized label instead of the enum key.
- Around line 314-315: The DataTables order and CSS column selectors must be
updated to account for the new tipo_media column: change the JS order config
currently using order: [[3, 'asc']] so it sorts the Info/title column instead of
Cover (update it to order: [[4, 'asc']]); update the CSS selector that uses
nth-child(4) for text wrapping to nth-child(5) so it targets Info/title; and
update the mobile-hide rules that currently use nth-child(4)/hide the Media icon
so they instead target the Cover/Media positions as intended (use nth-child(4)
for hiding the Media icon per the comment). Locate and change the exact
occurrences of the strings order: [[3, 'asc']], nth-child(4) (text-wrap) and the
two nth-child rules around the mobile hide comments to the corrected indices.
In `@app/Views/libri/partials/book_form.php`:
- Around line 424-433: The rendered select must only use keys from
MediaLabels::allTypes(), so ensure any incoming scraper value (e.g.
data.tipo_media assigned to $book['tipo_media']) is validated/mapped before
rendering or saving; replace direct use of $book['tipo_media'] with a normalized
value derived by checking array_key_exists($book['tipo_media'],
MediaLabels::allTypes()) (or intersecting keys) and falling back to a default
like 'libro' (or null) — update the view logic around the select and the code
path that assigns $book['tipo_media'] from scraper input to perform this
whitelist check so saved and displayed values always match
MediaLabels::allTypes() keys.
- Around line 427-428: The option value is rendered raw in the loop over
\App\Support\MediaLabels::allTypes() (variables $value and $meta) — update the
option tag to escape the attribute by wrapping $value with
htmlspecialchars($value, ENT_QUOTES, 'UTF-8') when outputting the value
attribute (keep the comparison ($book['tipo_media'] ?? 'libro') === $value using
the original unescaped $value). Ensure ENT_QUOTES and UTF-8 are used to follow
the repo view-escaping guideline.
---
Outside diff comments:
In `@app/Controllers/CsvImportController.php`:
- Around line 1229-1254: The UPDATE statement in CsvImportController (the
standalone "UPDATE libri SET ... WHERE id = ?") must include the soft-delete
predicate to match findExistingBook() and avoid updating a row that was
soft-deleted between lookup and write; modify the SQL in the CsvImportController
update prepared statement to add "AND deleted_at IS NULL" (so the WHERE clause
becomes "WHERE id = ? AND deleted_at IS NULL") so every write to the libri table
respects the soft-delete contract.
In `@app/Controllers/ScrapeController.php`:
- Around line 76-99: byIsbn() currently rejects non-ISBN barcodes so Discogs
never sees EAN/UPC inputs; update the validation path: change isValidIsbn() to
also accept common barcode formats (EAN-13, UPC-A 12, EAN-8) and ensure the
cleaned value ($cleanIsbn) is normalized (strip non-digits, uppercase) before
validation, keeping the existing hook call
(\App\Support\Hooks::apply('scrape.isbn.validate', ...)); this lets barcode
values pass the validation gate and reach scrape.fetch.custom and
normalizeIsbnFields() where source === 'discogs' is preserved.
---
Duplicate comments:
In `@app/Support/MediaLabels.php`:
- Around line 93-95: The helper returns raw external $text when parsing fails
(if $tracks === false || count($tracks) < 2 and similarly at lines 107-108),
which can emit untrusted markup; change those fallback returns to return an
escaped string instead (e.g., replace return $text with return e($text) in
Laravel or return htmlspecialchars($text, ENT_QUOTES, 'UTF-8') where e() isn't
available) so callers always get safely escaped HTML when $tracks or $text are
returned.
- Around line 14-35: The isMusic method currently treats any occurrence of
tokens like "audio" as music (via str_contains), causing
"audiolibro"/"audiobook" to be misclassified; update MediaLabels::isMusic to
match whole words or token boundaries instead of substring matches: replace the
str_contains loop with a regex word-boundary check (e.g., use preg_match with \b
and preg_quote for each entry) or remove overly-generic tokens like "audio" from
the $musicFormats list and only keep explicit music identifiers; reference the
static property MediaLabels::$musicFormats and the method MediaLabels::isMusic
when making the change. Ensure null/empty handling remains unchanged and that
matching is case-insensitive.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 5bb4cce7-9e90-491a-aac6-f9542e2d3b3c
📒 Files selected for processing (22)
app/Controllers/CsvImportController.phpapp/Controllers/FrontendController.phpapp/Controllers/LibraryThingImportController.phpapp/Controllers/LibriApiController.phpapp/Controllers/LibriController.phpapp/Controllers/PublicApiController.phpapp/Controllers/ScrapeController.phpapp/Models/BookRepository.phpapp/Support/MediaLabels.phpapp/Views/frontend/book-detail.phpapp/Views/frontend/catalog-grid.phpapp/Views/frontend/catalog.phpapp/Views/libri/index.phpapp/Views/libri/partials/book_form.phpapp/Views/libri/scheda_libro.phpinstaller/database/migrations/migrate_0.5.4.sqlinstaller/database/schema.sqllocale/de_DE.jsonlocale/en_US.jsonlocale/it_IT.jsonstorage/plugins/discogs/DiscogsPlugin.phptest-results/.last-run.json
✅ Files skipped from review due to trivial changes (4)
- test-results/.last-run.json
- installer/database/schema.sql
- locale/it_IT.json
- app/Views/libri/scheda_libro.php
🚧 Files skipped from review as they are similar to previous changes (1)
- app/Views/frontend/book-detail.php
| <!-- Media Type --> | ||
| <div class="filter-section"> | ||
| <div class="filter-title"> | ||
| <i class="fas fa-compact-disc"></i> | ||
| <?= __("Tipo Media") ?> | ||
| </div> | ||
| <div class="filter-options"> | ||
| <?php | ||
| $currentTipo = $filters['tipo_media'] ?? ''; | ||
| $tipoFilters = ['' => ['icon' => 'fa-th-large', 'label' => __('Tutti i media')]]; | ||
| foreach (\App\Support\MediaLabels::allTypes() as $tmValue => $tmMeta) { | ||
| $tipoFilters[$tmValue] = ['icon' => $tmMeta['icon'], 'label' => __($tmMeta['label'])]; | ||
| } | ||
| foreach ($tipoFilters as $tmValue => $tmInfo): | ||
| $isActive = $currentTipo === (string)$tmValue; | ||
| ?> | ||
| <a href="#" | ||
| class="filter-option <?= $isActive ? 'active' : '' ?>" | ||
| onclick="updateFilter('tipo_media', '<?= $tmValue ?>'); return false;"> | ||
| <i class="fas <?= $tmInfo['icon'] ?> me-1"></i> | ||
| <?= $tmInfo['label'] ?> | ||
| </a> | ||
| <?php endforeach; ?> | ||
| </div> | ||
| </div> |
There was a problem hiding this comment.
tipo_media is only half-wired on the client.
After updateFilter('tipo_media', ...), the sidebar list never reapplies its .active state, and updateActiveFiltersDisplay() falls back to raw enum keys (disco, dvd, …) because this filter has no value-label mapping. Mirroring syncAvailabilityActiveState() with a media-type sync path and emitting a label map from MediaLabels::allTypes() would keep the new filter consistent after AJAX updates.
Also applies to: 1539-1556, 1614-1667
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/Views/frontend/catalog.php` around lines 1302 - 1326, The new tipo_media
filter only updates the server but the client never reapplies active states or
label mapping; add a client-side sync path analogous to
syncAvailabilityActiveState() that listens for tipo_media changes and updates
the sidebar links' active class and the summary via
updateActiveFiltersDisplay(), and expose a JS label map built from
MediaLabels::allTypes() (emit the value=>label pairs from PHP into the page) so
updateActiveFiltersDisplay() shows human-friendly labels instead of raw enum
keys; ensure updateFilter('tipo_media', ...) triggers the same AJAX/DOM-refresh
flow as availability so the active state persists after AJAX updates.
- Add extractCredits() for Discogs extra artists (producers, engineers) - Map all Discogs fields: styles→keywords, notes, weight, price, track count, catalog number, physical description, credits, URL - Fix isMusic() false positive on "audiolibro" (exact match, not substring) - Escape formatTracklist() fallback text (XSS prevention) - updateBasic() only persists tipo_media when explicitly provided - Add format display name aliases (cd, vinyl, cassette, blu_ray)
- EAN→Barcode label for music in frontend book-detail - HTML escaping in catalog-grid badge, catalog filter, book_form option - Remove tipo_media default from LibriController (let scraped values flow) - CSV import: null tipo_media on update preserves existing value - DataTable column index shift after tipo_media column - German "Cartaceo" → "Druckausgabe" - Remove Discogs early-return guard (let merge fill missing fields)
|
@CodeRabbit full review |
✅ Actions performedFull review triggered. |
There was a problem hiding this comment.
Actionable comments posted: 12
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
app/Support/PluginManager.php (1)
45-53:⚠️ Potential issue | 🟠 MajorBundling Discogs here makes the “optional” plugin auto-activate.
Anything in
BUNDLED_PLUGINSis auto-registered withis_active = 1byautoRegisterBundledPlugins(). Addingdiscogshere means the next deploy/update turns it on for everyone, which bypasses the documented install/activate flow for an optional integration. Either keep Discogs out of the bundled allowlists, or add a bundled-but-disabled registration path.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Support/PluginManager.php` around lines 45 - 53, The BUNDLED_PLUGINS constant currently includes 'discogs', which causes autoRegisterBundledPlugins() to auto-activate that integration; remove 'discogs' from the BUNDLED_PLUGINS array in the PluginManager class so it is not auto-registered, or alternatively implement a bundled-but-disabled path inside autoRegisterBundledPlugins() that sets is_active = 0 for specific plugin keys (e.g., special-case 'discogs' in autoRegisterBundledPlugins() to create the record but leave is_active false). Ensure references to BUNDLED_PLUGINS and autoRegisterBundledPlugins() are updated consistently.
♻️ Duplicate comments (13)
app/Views/frontend/catalog.php (1)
1539-1553:⚠️ Potential issue | 🟡 Minor
tipo_mediafilter still lacks active-state sync and human-readable value labels.After updates, the active tag can still display raw enum values (
disco,dvd, …), and only availability has a dedicated active-state sync path.Also applies to: 1614-1653
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Views/frontend/catalog.php` around lines 1539 - 1553, The updateFilter function currently only triggers syncAvailabilityActiveState for 'disponibilita' but not for 'tipo_media', and active tags show raw enum values; add a dedicated sync for tipo_media (e.g., call a new or existing syncTipoMediaActiveState function from updateFilter when key === 'tipo_media') and ensure updateActiveFiltersDisplay (or the function that builds active tag labels) maps tipo_media enum keys ('disco','dvd', etc.) to human-readable labels before rendering; update any helper that reads currentFilters to use this mapping so active-state styles and display text are consistent.app/Views/libri/partials/book_form.php (1)
3615-3624:⚠️ Potential issue | 🟡 MinorValidate
data.tipo_mediaagainst allowed option keys before assignment.The JavaScript handler sets
tipoMediaSelect.valuedirectly fromdata.tipo_mediawithout validating that the value exists in the rendered<select>options. WhileScrapeControllerusesMediaLabels::inferTipoMedia()which returns valid keys, plugins like the Discogs scraper could return different values (e.g.,'cd','vinyl'), causing the select to show no selection or an invalid state.🛡️ Proposed fix to validate against allowed options
// Auto-set tipo_media from scraped data try { if (data.tipo_media) { const tipoMediaSelect = document.getElementById('tipo_media'); - if (tipoMediaSelect) { - tipoMediaSelect.value = data.tipo_media; + if (tipoMediaSelect && tipoMediaSelect.querySelector(`option[value="${CSS.escape(data.tipo_media)}"]`)) { + tipoMediaSelect.value = data.tipo_media; } } } catch (err) { }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Views/libri/partials/book_form.php` around lines 3615 - 3624, The handler sets tipo_mediaSelect.value directly from data.tipo_media without verifying the value exists in the rendered select options; update the logic around the tipo_media assignment (where data.tipo_media, tipoMediaSelect are referenced) to first check whether an option with that value exists (e.g., via tipoMediaSelect.querySelector or iterating tipoMediaSelect.options) and only set tipoMediaSelect.value if a matching option is present; if not present, skip the assignment or set a safe fallback, and keep references to ScrapeController/MediaLabels::inferTipoMedia in mind for expected keys.app/Views/libri/index.php (1)
688-697:⚠️ Potential issue | 🟡 MinorRender the localized media label here too.
The filter dropdown is localized, but the table tooltip and active-filter chip still show the raw enum key (
disco,audiolibro, ...). That leaves the UI partially untranslated and inconsistent with the filter options.Also applies to: 885-886
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Views/libri/index.php` around lines 688 - 697, The media column render currently sets the icon title to the raw enum key (in the render function for the column with data: 'tipo_media'), causing untranslated tooltips; update that render to look up the localized label (use the same localization map/function used by the filter dropdown) and include it in the title/tooltip alongside the icon (e.g., title = localizedLabel || data). Also update the active-filter chip rendering (the code that builds the filter chips around lines 885-886) to display the localized label instead of the raw enum key, using the same localization lookup so both tooltip and chip match the dropdown translations.storage/plugins/discogs/views/settings.php (2)
14-18:⚠️ Potential issue | 🟠 MajorDon’t save the masked placeholder back as the token.
DiscogsPlugin::getSettings()returns********when a token exists, and this view posts that value back throughsaveSettings(). Opening the page and saving without edits will overwrite the real token with literal asterisks. Keep the field empty, show token presence separately, and only overwrite the stored value on an explicit replace/clear action.Also applies to: 96-100
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@storage/plugins/discogs/views/settings.php` around lines 14 - 18, The form is currently writing the literal mask back into storage because the view posts the masked value; update the POST handling in the settings view so you only overwrite the stored token when the user explicitly provides a new value or requests a clear: when handling the save (the block using \App\Support\Csrf::validate and building $settings with 'api_token'), treat empty string or the mask value (DiscogsPlugin::getSettings() mask like "********") as “no change” (i.e., do not include api_token in $settings), and add/handle an explicit clear action (e.g., a separate checkbox or button whose POST field like clear_discogs_token signals to set api_token to empty) so saveSettings()/DiscogsPlugin::saveSettings() only updates the token on an explicit replace or clear.
133-137:⚠️ Potential issue | 🟡 MinorUse the named plugins route here.
Hardcoding the admin path in a view makes this link drift-prone if routes are renamed or localized. Build the
hreffrom the route helper and escape that result in the attribute.As per coding guidelines "Never use hardcoded routes. Use
route_path('key')orRouteTranslator::route('key')instead of hardcoded paths like/accedior/login" and "Usehtmlspecialchars()with ENT_QUOTES and UTF-8 encoding forroute_path()calls in HTML attributes".🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@storage/plugins/discogs/views/settings.php` around lines 133 - 137, The view hardcodes "/admin/plugins" in url('/admin/plugins'); replace that with the named route helper and escape it: call route_path('plugins.index') (or the correct named key for the admin plugins route) and wrap the result in htmlspecialchars(..., ENT_QUOTES, 'UTF-8') for the href; alternatively use RouteTranslator::route('plugins.index') if your app uses that helper. Update the anchor to use htmlspecialchars(route_path('YOUR_ROUTE_KEY'), ENT_QUOTES, 'UTF-8') instead of url('/admin/plugins').app/Views/frontend/book-detail.php (1)
2187-2189:⚠️ Potential issue | 🟡 MinorUse the related item’s own media type for this fallback.
This still keys off the parent page’s
$isMusic, so a mixed related-items section can show "Artista sconosciuto" or "Autore sconosciuto" based on the current book instead of the related card.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Views/frontend/book-detail.php` around lines 2187 - 2189, The fallback text for author vs artist currently uses the parent page's $isMusic; change it to use the related item's media flag so each related card decides its own label. Update the expression in the related card (where $related['autori'] is used) to compute a per-card flag from the related data, e.g. $relatedIsMusic = $related['isMusic'] ?? $related['media_type'] === 'music' ?? $isMusic, and then use that ($relatedIsMusic) instead of $isMusic when choosing __('Artista sconosciuto' : 'Autore sconosciuto'); ensure you reference the $related array keys that exist in your dataset (replace 'isMusic'/'media_type' with the actual key if different).tests/discogs-plugin.spec.js (2)
91-118:⚠️ Potential issue | 🟠 MajorScope install/activate/settings to the Discogs card and assert they exist.
Line 94 clicks the first
Installabutton on the page if the generic Discogs locator misses, so this suite can mutate a different plugin and then silently continue. Root the install, activate, and settings locators in the Discogs card and fail if that card or its controls are missing.Also applies to: 129-137
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/discogs-plugin.spec.js` around lines 91 - 118, The test currently clicks a generic first "Installa" / "Attiva" button which can target another plugin; scope all install/activate/settings locators to the Discogs card (use the existing discogsCard locator and call discogsCard.locator(...) or similar) and assert the card and each control are present/visible before interacting, failing the test if the discogsCard or its install/activate/settings controls are missing; update usage of installBtn and activateBtn to be rooted off discogsCard and replace silent .catch(() => false) flows with explicit assertions so the test cannot mutate a different plugin.
193-202:⚠️ Potential issue | 🟠 MajorThis "frontend" assertion still exercises the admin page.
Line 194 requests
/admin/libri/${bookId}, so it never validates the public detail template or the music-specificBarcodelabel there. Resolve the real public book URL and assert against that response instead.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/discogs-plugin.spec.js` around lines 193 - 202, The test is requesting the admin URL (`page.request.get(`${BASE}/admin/libri/${bookId}`)`) but intends to assert the public book detail template; change the request to the public path (`page.request.get(`${BASE}/libri/${bookId}`)`), keep the same HTML checks for 'Barcode' or music-specific labels, and update the comment to indicate it's validating the public detail page rather than the admin page; reference the variables BASE and bookId and the request call to locate the change.tests/discogs-import.spec.js (4)
155-164:⚠️ Potential issue | 🟠 MajorTreat a missing persisted row as a failure, not a skip.
Line 160 skips when neither lookup finds the saved record. That hides a broken save path and lets the remaining checks run on invalid state.
Suggested fix
- if (book === '') { - // Try isbn13 - const bookByIsbn = dbQuery( - `SELECT titolo, isbn13, formato FROM libri WHERE isbn13 LIKE '%720642442524%' AND deleted_at IS NULL LIMIT 1` - ); - test.skip(bookByIsbn === '', 'CD not found in database'); - if (bookByIsbn) { - expect(bookByIsbn.toLowerCase()).toContain('nevermind'); - } - return; - } - - expect(book.toLowerCase()).toContain('nevermind'); + let persisted = book; + if (persisted === '') { + persisted = dbQuery( + `SELECT titolo, isbn13, formato FROM libri WHERE isbn13 LIKE '%720642442524%' AND deleted_at IS NULL LIMIT 1` + ); + } + + expect(persisted).not.toBe(''); + expect(persisted.toLowerCase()).toContain('nevermind');🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/discogs-import.spec.js` around lines 155 - 164, The test currently treats a missing persisted row as a skip (see the book variable check and test.skip call). Replace the test.skip call with a failing assertion so that when dbQuery (the bookByIsbn lookup) returns no result the test fails immediately; e.g., assert or test.fail with a clear message that the expected DB row was not saved, and only run the expect(bookByIsbn.toLowerCase()).toContain(...) when bookByIsbn is truthy. Ensure you update the block around the book, dbQuery, test.skip, and expect references to reflect this change.
77-79:⚠️ Potential issue | 🟠 MajorFail this flow when import UI or scrape output is missing.
Lines 77-79 and 94-97 currently turn real regressions into skips. After plugin activation, a missing import button or an empty
titolomeans the Discogs path is broken.Suggested fix
- if (!await importBtn.isVisible({ timeout: 5000 }).catch(() => false)) { - test.skip(true, 'Import button not visible — no scraping plugin active'); - return; - } + await expect(importBtn).toBeVisible({ timeout: 5000 }); @@ - if (titleValue === '') { - // Scraping may have failed (rate limit, network). Check if any source populated data - test.skip(true, 'Scraping did not return data (possibly rate limited)'); - return; - } + expect( + titleValue, + `Discogs scrape returned no data for barcode ${TEST_BARCODE}` + ).not.toBe('');Also applies to: 94-97
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/discogs-import.spec.js` around lines 77 - 79, The current checks silently skip on missing UI/scrape results; instead fail the test when the import button or the scraped titolo is missing by replacing the skip logic with a failing assertion or explicit throw—locate the importBtn visibility check (importBtn.isVisible(...)) and change the branch to call test.fail(...) or throw new Error('Import button not visible — no scraping plugin active'), and likewise find the titolo emptiness check (the variable titolo and its test.skip branch) and replace it with test.fail(...) or throw new Error('Scrape produced empty titolo — Discogs path broken') so missing UI or empty scrape output causes a test failure.
28-28:⚠️ Potential issue | 🟠 Major
serialhere does not prevent cross-file DB races.Line 28 only serializes tests inside this file. Playwright can still run the other DB-backed specs in parallel unless the runner is pinned to one worker via
/tmp/run-e2e.shor Playwright config.Expected result: either
workers: 1appears in Playwright config or the E2E runner passes--workers=1, and/tmp/run-e2e.shexports the requiredE2E_*variables.#!/bin/bash set -e echo "=== Playwright config ===" fd -i 'playwright.config.*' -x sh -c ' echo "--- $1 ---" sed -n "1,200p" "$1" | rg -n "workers|fullyParallel|testDir" ' sh {} echo echo "=== package.json scripts ===" fd -i 'package.json' -x sh -c ' echo "--- $1 ---" rg -n "\"(e2e|playwright|test)\"" "$1" -A2 -B1 ' sh {} echo echo "=== /tmp/run-e2e.sh ===" if [ -f /tmp/run-e2e.sh ]; then sed -n '1,200p' /tmp/run-e2e.sh | rg -n 'playwright|workers|E2E_' else echo "/tmp/run-e2e.sh not available in sandbox" fiBased on learnings, "E2E tests with Playwright require
/tmp/run-e2e.shto set DB/admin credentials as env vars. Always use--workers=1for serial execution".🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/discogs-import.spec.js` at line 28, The test uses test.describe.serial('Discogs Import: full scraping flow') which only serializes tests inside this file and does not prevent cross-file DB races; update the E2E runner so Playwright runs with a single worker (either set workers: 1 in the Playwright config or ensure the test runner is invoked with --workers=1) and verify /tmp/run-e2e.sh exports the required E2E_* environment variables (DB/admin creds) before launching Playwright; ensure any CI or package.json e2e script that calls Playwright includes the --workers=1 flag or the config change so DB-backed specs cannot run in parallel.
50-52:⚠️ Potential issue | 🟠 MajorClean up both barcode persistence paths.
Line 52 only deletes by
ean, but Lines 118-122 and 157-159 treatisbn13as a valid save target too. A previous run can leave theisbn13row behind and make the DB assertions pass against stale data.Suggested fix
test.afterAll(async () => { // Cleanup test data - try { dbExec("DELETE FROM libri WHERE ean = '0720642442524' AND deleted_at IS NULL"); } catch {} + try { + const normalizedBarcode = TEST_BARCODE.slice(1); + dbExec( + `DELETE FROM libri + WHERE (ean = '${TEST_BARCODE}' OR isbn13 LIKE '%${normalizedBarcode}%') + AND deleted_at IS NULL` + ); + } catch {} await context?.close(); });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/discogs-import.spec.js` around lines 50 - 52, The test.afterAll cleanup only deletes rows by ean and misses rows saved under isbn13; update the cleanup in the test.afterAll block that calls dbExec so it removes both persistence paths for the barcode used in tests (targeting the libri table), e.g. run a single DELETE that includes "WHERE (ean = '0720642442524' OR isbn13 = '0720642442524') AND deleted_at IS NULL" or execute two dbExec deletes (one for ean and one for isbn13) to ensure no stale rows remain for the test barcode.storage/plugins/discogs/DiscogsPlugin.php (1)
696-752:⚠️ Potential issue | 🟠 MajorDon't round-trip
api_tokenthrough raw SQL.Lines 725-739 and 796-813 read and write the Discogs personal access token verbatim from
plugin_settings. That stores a secret in plaintext and creates a second settings path that can drift from the app's centralized plugin-settings handling.Also applies to: 761-820
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@storage/plugins/discogs/DiscogsPlugin.php` around lines 696 - 752, The getSetting()/getSettings() code directly queries plugin_settings and returns the api_token plaintext; remove this direct SQL access and stop round-tripping the secret through plugin_settings. Replace the manual SQL in getSetting (and any code that writes api_token) with calls to the app's central plugin-settings API/manager (use the existing centralized accessor used elsewhere in the project) so the Discogs plugin reads/writes the token through the unified settings layer and preserves secret handling (no plaintext return in getSettings — mask or omit the token). Update any references to $this->pluginId/$this->db in getSetting to use that centralized API instead of raw queries.
🧹 Nitpick comments (1)
app/Models/BookRepository.php (1)
1000-1000:tipo_mediais not normalized inupdateOptionals.Unlike
createBasicandupdateBasic, this path storestipo_media(andscraped_tipo_media) without passing throughnormalizeEnumValue(). If a scraper returns an unexpected value, it could violate the DB enum constraint.Consider normalizing the value:
Suggested improvement
} else { - $cols[$c] = $data[$c]; + $cols[$c] = $c === 'tipo_media' + ? $this->normalizeEnumValue((string) $data[$c], 'tipo_media', 'libro') + : $data[$c]; }And similarly for
scraped_tipo_media:if ($this->hasColumn('tipo_media') && !array_key_exists('tipo_media', $cols) && !empty($data['scraped_tipo_media'])) { - $cols['tipo_media'] = (string) $data['scraped_tipo_media']; + $cols['tipo_media'] = $this->normalizeEnumValue((string) $data['scraped_tipo_media'], 'tipo_media', 'libro'); }Also applies to: 1055-1057
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Models/BookRepository.php` at line 1000, In updateOptionals, the loop that assigns optional fields (foreach over ['numero_pagine', 'ean', ... 'tipo_media']) currently writes tipo_media and scraped_tipo_media without normalization; modify updateOptionals to pass values for 'tipo_media' and 'scraped_tipo_media' through the same normalizeEnumValue() used by createBasic/updateBasic before persisting to ensure DB enum constraints aren't violated (identify the assignment site inside updateOptionals where tipo_media and scraped_tipo_media are set and replace direct assignment with normalizeEnumValue($value) for those keys).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/Controllers/PluginController.php`:
- Around line 398-406: The code currently ignores the boolean result of
$this->pluginManager->setSetting($pluginId, 'api_token', $apiToken, true) so the
endpoint can return success even if persistence failed; update the handler in
PluginController to capture the return value (e.g. $saved =
$this->pluginManager->setSetting(...)), and if $saved is false return an error
JSON response and appropriate non-200 status (and do not claim has_token true),
otherwise continue to return the success payload; adjust the response body and
status accordingly so the API accurately reflects whether setSetting succeeded.
In `@app/Support/MediaLabels.php`:
- Around line 21-38: The function isMusic should let the provided $tipoMedia be
authoritative: change isMusic so the first check is if ($tipoMedia !== null)
return $tipoMedia === 'disco'; i.e. if tipo_media is set, return true only for
the explicit music type(s) you consider music (e.g. 'disco') and otherwise
return false, instead of falling back to evaluating $formato; update the
early-return logic in isMusic to use $tipoMedia as the decisive value.
- Around line 165-182: The inferTipoMedia function misclassifies cassette
formats like "audiocassetta" as libro; update the matching list inside
inferTipoMedia to include the cassette variants used elsewhere (e.g., add
patterns such as 'audiocassetta', 'audio_cassetta', 'audio-cassetta', and any
spaced forms like 'audio cassetta') so those inputs hit the cassette branch and
return 'disco' (modify the array in the foreach that currently contains
['cd_audio', 'vinile', 'lp', 'cd', 'vinyl', 'cassetta', 'cassette'] to include
the additional cassette variants).
In `@app/Views/frontend/book-detail.php`:
- Around line 27-29: The media-type detection is duplicated: $isMusic is derived
from both 'tipo_media' and fallback 'formato' using
\App\Support\MediaLabels::isMusic, but the hero badge and schemaOrgType() still
read 'tipo_media' directly; amend the template to resolve the media type once
(e.g., compute $isMusic and/or a normalized $resolvedMediaType using
MediaLabels::isMusic and the 'formato' fallback) and then pass or use that
resolved value when rendering the hero badge, the tracklist/music labels, and
when calling schemaOrgType() so all places (hero badge, JSON‑LD, and labels)
consistently treat records inferred as music from formato as music.
In `@app/Views/frontend/catalog.php`:
- Around line 1321-1322: Escape the dynamic icon class and label when rendering
the media filter option: replace unescaped uses of $tmInfo['icon'] and
$tmInfo['label'] in the view with htmlspecialchars(..., ENT_QUOTES, 'UTF-8')
(e.g., wrap $tmInfo['icon'] passed into the <i class="fas ..."> and wrap
$tmInfo['label'] output) so both the HTML attribute and visible text are
properly escaped; update the occurrences in frontend/catalog.php where
$tmInfo['icon'] and $tmInfo['label'] are rendered.
In `@app/Views/libri/scheda_libro.php`:
- Around line 8-10: The header badge is still reading only libro['tipo_media']
while $isMusic uses a fallback to formato; fix by resolving a single media type
variable (e.g., $mediaType = $libro['tipo_media'] ?? $libro['formato'] ?? '')
and use that for both the music detection (via
\App\Support\MediaLabels::isMusic($libro['formato'] ?? null,
$libro['tipo_media'] ?? null) or better
\App\Support\MediaLabels::isMusic($mediaType)) and the header badge logic
(replace direct uses of libro['tipo_media'] in the badge rendering and the block
around lines 72-75 with the resolved $mediaType) so the badge and $isMusic
detection use the same fallback source.
In `@installer/database/migrations/migrate_0.5.4.sql`:
- Around line 1-38: The migration file migrate_0.5.4.sql is named for a higher
version than the packaged release (pinakes-v0.5.3.zip.sha256) so
Updater::runMigrations() (which only runs migrations with version <= $toVersion)
will skip creating libri.tipo_media; fix by either renaming migrate_0.5.4.sql to
migrate_0.5.3.sql so its version ≤ the packaged release, or increment the
packaged release/version to 0.5.4 (and update pinakes-v0.5.3.zip.sha256
accordingly) so the migration is applied—ensure the migration filename version
matches the release version used by Updater::runMigrations().
In `@storage/plugins/discogs/DiscogsPlugin.php`:
- Around line 203-206: When Discogs barcode lookup returns no hits within
fetchFromDiscogs(), don't return immediately; implement the promised
title/artist fallback by using the provided $currentResult (access
$currentResult->title and $currentResult->author) to perform a query-based
search before giving up. Update the barcode-null handling path (and the similar
block around the earlier return) to call the existing query/search routine with
those title/author values as a targeted fallback, and only return after this
fallback has been attempted and exhausted.
In `@storage/plugins/discogs/README.md`:
- Around line 49-70: The README and current implementation use a fixed 1s pause
between Discogs requests which allows ~60/min and will cause 429s for
unauthenticated usage; update the plugin's rate-limiting logic to be token-aware
by checking the `api_token` setting and applying a different throttle: when
`api_token` is present allow 1s between requests (≈60/min), otherwise set the
delay to at least 2.4s per request (≈25/min) or implement a tokenless budget of
25 requests/min; also add 429 handling with exponential backoff/retry and log
details so the path that reads `api_token` and the rate limiter (the
module/function that enforces the 1-second pause) uses the new token-aware
delays.
- Around line 15-17: Update the fallback search example in the README: replace
the incorrect barcode placeholder in the second step (`GET
/database/search?q={ean}&type=release`) with a title-based query such as `GET
/database/search?q={title}&type=release` or `GET
/database/search?q={title}+{artist}&type=release` to match the described
second-pass title/title+artist search; keep the first and third steps (`GET
/database/search?barcode={ean}&type=release` and `GET /releases/{id}`)
unchanged.
In `@test-results/.last-run.json`:
- Around line 1-4: Remove the ephemeral test runner state file (.last-run.json)
from version control and stop committing it: delete the tracked file from the
repo (git rm --cached .last-run.json or remove then commit), add an entry for
.last-run.json to your .gitignore (or the appropriate test-results/ pattern),
and commit the change so future Playwright runs won't create PR churn; verify no
other ephemeral test artifacts are still tracked in the repository.
In `@tests/discogs-import.spec.js`:
- Around line 181-188: The test's guard uses the `formato` query which can be
wrong and skips the label assertion; change the DB query to fetch `tipo_media`
instead (use dbQuery to SELECT tipo_media FROM libri WHERE id = ${bookId}) and
replace the `if (formato && ['cd_audio' ...].some(...))` check with a check that
runs the label assertion when `tipo_media === 'disco'`; keep the `hasMusicLabel`
check (looking at `content.includes('Etichetta')` / `Label` / `Anno di Uscita` /
`Release Year'`) and `expect(hasMusicLabel).toBe(true)` so the assertion doesn't
become a no-op when `formato` mapping is wrong.
---
Outside diff comments:
In `@app/Support/PluginManager.php`:
- Around line 45-53: The BUNDLED_PLUGINS constant currently includes 'discogs',
which causes autoRegisterBundledPlugins() to auto-activate that integration;
remove 'discogs' from the BUNDLED_PLUGINS array in the PluginManager class so it
is not auto-registered, or alternatively implement a bundled-but-disabled path
inside autoRegisterBundledPlugins() that sets is_active = 0 for specific plugin
keys (e.g., special-case 'discogs' in autoRegisterBundledPlugins() to create the
record but leave is_active false). Ensure references to BUNDLED_PLUGINS and
autoRegisterBundledPlugins() are updated consistently.
---
Duplicate comments:
In `@app/Views/frontend/book-detail.php`:
- Around line 2187-2189: The fallback text for author vs artist currently uses
the parent page's $isMusic; change it to use the related item's media flag so
each related card decides its own label. Update the expression in the related
card (where $related['autori'] is used) to compute a per-card flag from the
related data, e.g. $relatedIsMusic = $related['isMusic'] ??
$related['media_type'] === 'music' ?? $isMusic, and then use that
($relatedIsMusic) instead of $isMusic when choosing __('Artista sconosciuto' :
'Autore sconosciuto'); ensure you reference the $related array keys that exist
in your dataset (replace 'isMusic'/'media_type' with the actual key if
different).
In `@app/Views/frontend/catalog.php`:
- Around line 1539-1553: The updateFilter function currently only triggers
syncAvailabilityActiveState for 'disponibilita' but not for 'tipo_media', and
active tags show raw enum values; add a dedicated sync for tipo_media (e.g.,
call a new or existing syncTipoMediaActiveState function from updateFilter when
key === 'tipo_media') and ensure updateActiveFiltersDisplay (or the function
that builds active tag labels) maps tipo_media enum keys ('disco','dvd', etc.)
to human-readable labels before rendering; update any helper that reads
currentFilters to use this mapping so active-state styles and display text are
consistent.
In `@app/Views/libri/index.php`:
- Around line 688-697: The media column render currently sets the icon title to
the raw enum key (in the render function for the column with data:
'tipo_media'), causing untranslated tooltips; update that render to look up the
localized label (use the same localization map/function used by the filter
dropdown) and include it in the title/tooltip alongside the icon (e.g., title =
localizedLabel || data). Also update the active-filter chip rendering (the code
that builds the filter chips around lines 885-886) to display the localized
label instead of the raw enum key, using the same localization lookup so both
tooltip and chip match the dropdown translations.
In `@app/Views/libri/partials/book_form.php`:
- Around line 3615-3624: The handler sets tipo_mediaSelect.value directly from
data.tipo_media without verifying the value exists in the rendered select
options; update the logic around the tipo_media assignment (where
data.tipo_media, tipoMediaSelect are referenced) to first check whether an
option with that value exists (e.g., via tipoMediaSelect.querySelector or
iterating tipoMediaSelect.options) and only set tipoMediaSelect.value if a
matching option is present; if not present, skip the assignment or set a safe
fallback, and keep references to ScrapeController/MediaLabels::inferTipoMedia in
mind for expected keys.
In `@storage/plugins/discogs/DiscogsPlugin.php`:
- Around line 696-752: The getSetting()/getSettings() code directly queries
plugin_settings and returns the api_token plaintext; remove this direct SQL
access and stop round-tripping the secret through plugin_settings. Replace the
manual SQL in getSetting (and any code that writes api_token) with calls to the
app's central plugin-settings API/manager (use the existing centralized accessor
used elsewhere in the project) so the Discogs plugin reads/writes the token
through the unified settings layer and preserves secret handling (no plaintext
return in getSettings — mask or omit the token). Update any references to
$this->pluginId/$this->db in getSetting to use that centralized API instead of
raw queries.
In `@storage/plugins/discogs/views/settings.php`:
- Around line 14-18: The form is currently writing the literal mask back into
storage because the view posts the masked value; update the POST handling in the
settings view so you only overwrite the stored token when the user explicitly
provides a new value or requests a clear: when handling the save (the block
using \App\Support\Csrf::validate and building $settings with 'api_token'),
treat empty string or the mask value (DiscogsPlugin::getSettings() mask like
"********") as “no change” (i.e., do not include api_token in $settings), and
add/handle an explicit clear action (e.g., a separate checkbox or button whose
POST field like clear_discogs_token signals to set api_token to empty) so
saveSettings()/DiscogsPlugin::saveSettings() only updates the token on an
explicit replace or clear.
- Around line 133-137: The view hardcodes "/admin/plugins" in
url('/admin/plugins'); replace that with the named route helper and escape it:
call route_path('plugins.index') (or the correct named key for the admin plugins
route) and wrap the result in htmlspecialchars(..., ENT_QUOTES, 'UTF-8') for the
href; alternatively use RouteTranslator::route('plugins.index') if your app uses
that helper. Update the anchor to use
htmlspecialchars(route_path('YOUR_ROUTE_KEY'), ENT_QUOTES, 'UTF-8') instead of
url('/admin/plugins').
In `@tests/discogs-import.spec.js`:
- Around line 155-164: The test currently treats a missing persisted row as a
skip (see the book variable check and test.skip call). Replace the test.skip
call with a failing assertion so that when dbQuery (the bookByIsbn lookup)
returns no result the test fails immediately; e.g., assert or test.fail with a
clear message that the expected DB row was not saved, and only run the
expect(bookByIsbn.toLowerCase()).toContain(...) when bookByIsbn is truthy.
Ensure you update the block around the book, dbQuery, test.skip, and expect
references to reflect this change.
- Around line 77-79: The current checks silently skip on missing UI/scrape
results; instead fail the test when the import button or the scraped titolo is
missing by replacing the skip logic with a failing assertion or explicit
throw—locate the importBtn visibility check (importBtn.isVisible(...)) and
change the branch to call test.fail(...) or throw new Error('Import button not
visible — no scraping plugin active'), and likewise find the titolo emptiness
check (the variable titolo and its test.skip branch) and replace it with
test.fail(...) or throw new Error('Scrape produced empty titolo — Discogs path
broken') so missing UI or empty scrape output causes a test failure.
- Line 28: The test uses test.describe.serial('Discogs Import: full scraping
flow') which only serializes tests inside this file and does not prevent
cross-file DB races; update the E2E runner so Playwright runs with a single
worker (either set workers: 1 in the Playwright config or ensure the test runner
is invoked with --workers=1) and verify /tmp/run-e2e.sh exports the required
E2E_* environment variables (DB/admin creds) before launching Playwright; ensure
any CI or package.json e2e script that calls Playwright includes the --workers=1
flag or the config change so DB-backed specs cannot run in parallel.
- Around line 50-52: The test.afterAll cleanup only deletes rows by ean and
misses rows saved under isbn13; update the cleanup in the test.afterAll block
that calls dbExec so it removes both persistence paths for the barcode used in
tests (targeting the libri table), e.g. run a single DELETE that includes "WHERE
(ean = '0720642442524' OR isbn13 = '0720642442524') AND deleted_at IS NULL" or
execute two dbExec deletes (one for ean and one for isbn13) to ensure no stale
rows remain for the test barcode.
In `@tests/discogs-plugin.spec.js`:
- Around line 91-118: The test currently clicks a generic first "Installa" /
"Attiva" button which can target another plugin; scope all
install/activate/settings locators to the Discogs card (use the existing
discogsCard locator and call discogsCard.locator(...) or similar) and assert the
card and each control are present/visible before interacting, failing the test
if the discogsCard or its install/activate/settings controls are missing; update
usage of installBtn and activateBtn to be rooted off discogsCard and replace
silent .catch(() => false) flows with explicit assertions so the test cannot
mutate a different plugin.
- Around line 193-202: The test is requesting the admin URL
(`page.request.get(`${BASE}/admin/libri/${bookId}`)`) but intends to assert the
public book detail template; change the request to the public path
(`page.request.get(`${BASE}/libri/${bookId}`)`), keep the same HTML checks for
'Barcode' or music-specific labels, and update the comment to indicate it's
validating the public detail page rather than the admin page; reference the
variables BASE and bookId and the request call to locate the change.
---
Nitpick comments:
In `@app/Models/BookRepository.php`:
- Line 1000: In updateOptionals, the loop that assigns optional fields (foreach
over ['numero_pagine', 'ean', ... 'tipo_media']) currently writes tipo_media and
scraped_tipo_media without normalization; modify updateOptionals to pass values
for 'tipo_media' and 'scraped_tipo_media' through the same normalizeEnumValue()
used by createBasic/updateBasic before persisting to ensure DB enum constraints
aren't violated (identify the assignment site inside updateOptionals where
tipo_media and scraped_tipo_media are set and replace direct assignment with
normalizeEnumValue($value) for those keys).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 739bc1b7-a6b4-4f3f-8906-47428c6773b5
📒 Files selected for processing (33)
.gitignoreapp/Controllers/CsvImportController.phpapp/Controllers/FrontendController.phpapp/Controllers/LibraryThingImportController.phpapp/Controllers/LibriApiController.phpapp/Controllers/LibriController.phpapp/Controllers/PluginController.phpapp/Controllers/PublicApiController.phpapp/Controllers/ScrapeController.phpapp/Models/BookRepository.phpapp/Support/MediaLabels.phpapp/Support/PluginManager.phpapp/Support/Updater.phpapp/Views/frontend/book-detail.phpapp/Views/frontend/catalog-grid.phpapp/Views/frontend/catalog.phpapp/Views/libri/index.phpapp/Views/libri/partials/book_form.phpapp/Views/libri/scheda_libro.phpinstaller/database/migrations/migrate_0.5.4.sqlinstaller/database/schema.sqllocale/de_DE.jsonlocale/en_US.jsonlocale/it_IT.jsonpinakes-v0.5.3.zip.sha256storage/plugins/discogs/DiscogsPlugin.phpstorage/plugins/discogs/README.mdstorage/plugins/discogs/plugin.jsonstorage/plugins/discogs/views/settings.phpstorage/plugins/discogs/wrapper.phptest-results/.last-run.jsontests/discogs-import.spec.jstests/discogs-plugin.spec.js
storage/plugins/discogs/README.md
Outdated
| 1. Ricerca per barcode (EAN/UPC) -- `GET /database/search?barcode={ean}&type=release` | ||
| 2. Se nessun risultato, ricerca per query -- `GET /database/search?q={ean}&type=release` | ||
| 3. Recupero dettagli completi della release -- `GET /releases/{id}` |
There was a problem hiding this comment.
Fix the fallback search example.
Line 16 still shows the barcode placeholder in the fallback query. The PR objective says the second pass is title search, so this doc should describe the actual title/title+artist query instead of q={ean}.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@storage/plugins/discogs/README.md` around lines 15 - 17, Update the fallback
search example in the README: replace the incorrect barcode placeholder in the
second step (`GET /database/search?q={ean}&type=release`) with a title-based
query such as `GET /database/search?q={title}&type=release` or `GET
/database/search?q={title}+{artist}&type=release` to match the described
second-pass title/title+artist search; keep the first and third steps (`GET
/database/search?barcode={ean}&type=release` and `GET /releases/{id}`)
unchanged.
- isMusic(): tipoMedia is now authoritative (no formato fallback when set) - inferTipoMedia(): add audiocassetta to disco formats - Settings: empty password field, skip save on empty, "Token configurato" indicator - PluginController: check setSetting result, return 500 on failure - Resolve tipoMedia once for badge + Schema.org in book-detail and scheda_libro - README: remove fallback search, document token-aware rate limiting - Escape catalog filter icon/label with htmlspecialchars - normalizeEnumValue in updateOptionals for tipo_media - Remove test-results/.last-run.json from tracking - Add "Token configurato" translations (IT/EN/DE)
1. Admin list tipo_media=disco filter via DataTable API 2. CSV export includes tipo_media column with correct value 3. Schema.org tipo_media stored as 'disco' vs 'libro' in DB 4. Admin detail: music labels + tracklist vs book labels + prose 5. Edit persistence: tipo_media='disco' survives form save
Was showing raw 'Tracklist: 1. Come Together (4:19) 2. Something...' as plain text. Now calls MediaLabels::formatTracklist() which converts to <ol class='tracklist'> with individual <li> per track. Also shows music icon + 'Tracklist' label instead of 'Descrizione'.
|
@CodeRabbit full review |
✅ Actions performedFull review triggered. |
Single plugin, three sources in cascade: 1. Discogs (barcode search, primary) 2. MusicBrainz + Cover Art Archive (barcode fallback, open data) 3. Deezer (cover HD enrichment by title+artist) - MusicBrainz: barcode search, full release with artists/labels/recordings - Cover Art Archive: front cover in high resolution - Deezer: cover_xl (1000x1000) when other sources lack images - Each API has independent rate limiting (1s Discogs, 1.1s MB, 1s Deezer) - Plugin display name: "Music Scraper (Discogs, MusicBrainz, Deezer)"
There was a problem hiding this comment.
Actionable comments posted: 9
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
app/Support/Updater.php (1)
39-47:⚠️ Potential issue | 🟠 MajorRollback does not cover bundled plugin files.
Adding
discogshere meansinstallUpdate()will updatestorage/plugins/discogsviaupdateBundledPlugins(), butbackupAppFiles()/restoreAppFiles()never snapshotstorage/plugins. If install or migrations fail after that copy, rollback restores the core and leaves Discogs on the newer version.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Support/Updater.php` around lines 39 - 47, The rollback path doesn't snapshot storage/plugins so updating bundled plugins (BUNDLED_PLUGINS used by updateBundledPlugins() called from installUpdate()) can leave plugins upgraded when core is restored; modify backupAppFiles() and restoreAppFiles() to include the storage/plugins directory (or alternatively make updateBundledPlugins() write plugin updates to a temporary location and atomically move them only after install/migrations succeed), ensuring any plugin paths touched by BUNDLED_PLUGINS are backed up and restored on failure.app/Support/PluginManager.php (1)
45-53:⚠️ Potential issue | 🟠 MajorThis makes Discogs auto-active, not optional.
autoRegisterBundledPlugins()inserts bundled plugins withis_active = 1and immediately runsonActivate(). Once the Discogs folder ships in the app package, startup/update will silently enable it for every instance instead of waiting for an admin opt-in.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Support/PluginManager.php` around lines 45 - 53, The bundled "discogs" entry is being auto-enabled because autoRegisterBundledPlugins() inserts entries with is_active = 1 and immediately calls onActivate(); change the behavior so bundled optional plugins are registered but not auto-activated: either remove 'discogs' from the BUNDLED_PLUGINS constant and add it to a new BUNDLED_OPTIONAL_PLUGINS list, or modify autoRegisterBundledPlugins() to insert with is_active = 0 and skip calling onActivate() for items flagged optional (check for a new optionalPlugins array or a lookup), keeping onActivate() calls only for truly required plugins; update references to BUNDLED_PLUGINS, autoRegisterBundledPlugins(), is_active and onActivate() accordingly.app/Controllers/CsvImportController.php (1)
1231-1256:⚠️ Potential issue | 🟡 MinorAdd the soft-delete guard to this
libriupdate.
findExistingBook()already filters out soft-deleted rows, but this write still updates byidalone. A concurrent soft-delete between lookup and update will mutate archived data, and the query also violates the repository rule forlibri.🩹 Minimal fix
- WHERE id = ? + WHERE id = ? AND deleted_at IS NULLAs per coding guidelines, "Every query on
libritable MUST include soft-delete condition:AND deleted_at IS NULL".🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Controllers/CsvImportController.php` around lines 1231 - 1256, The UPDATE statement prepared in CsvImportController (the $stmt prepared for table libri) must include the soft-delete guard so we only update non-deleted rows; modify the WHERE clause from "WHERE id = ?" to "WHERE id = ? AND deleted_at IS NULL" (or equivalent parameterized condition) so a concurrent soft-delete won’t allow updates to archived rows; ensure the change is applied to the prepared SQL used by the update path that complements findExistingBook().
♻️ Duplicate comments (14)
app/Views/libri/partials/book_form.php (1)
3615-3624:⚠️ Potential issue | 🟡 MinorWhitelist and normalize scraped
tipo_mediabefore assigning it.At Line 3620, assigning raw
data.tipo_mediacan silently miss valid select keys (e.g., provider sendsCD/vinyl) and leave the form in an inconsistent state. Restrict assignment to known keys fromMediaLabels::allTypes().🔧 Suggested fix
// Auto-set tipo_media from scraped data try { if (data.tipo_media) { const tipoMediaSelect = document.getElementById('tipo_media'); - if (tipoMediaSelect) { - tipoMediaSelect.value = data.tipo_media; + const allowedTipoMedia = new Set(<?= json_encode(array_keys(\App\Support\MediaLabels::allTypes()), JSON_HEX_TAG) ?>); + const normalizedTipoMedia = String(data.tipo_media).trim().toLowerCase(); + if (tipoMediaSelect && allowedTipoMedia.has(normalizedTipoMedia)) { + tipoMediaSelect.value = normalizedTipoMedia; } } } catch (err) { }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Views/libri/partials/book_form.php` around lines 3615 - 3624, The code sets the select `#tipo_media` directly from data.tipo_media which may contain variants (e.g., "CD", "vinyl") and leaves the select unset; instead whitelist and normalize the incoming value against the canonical keys from MediaLabels::allTypes(): create a mapping of allowed keys/aliases (server-side output of MediaLabels::allTypes() into a JS object or array), normalize data.tipo_media (trim, lower/upper-case as needed), look up the normalized value in that whitelist (or map aliases to canonical keys) and only assign tipoMediaSelect.value when a match exists; keep the try/catch but log or silently skip when no valid match is found to avoid inconsistent form state.app/Views/frontend/catalog.php (1)
1302-1326:⚠️ Potential issue | 🟡 Minor
tipo_mediafilter is still only partially wired on the client.Line 1302-1326 adds the filter UI, but there is still no client-side active-state sync path (like availability), and active-filter tags still show raw enum keys rather than user-facing labels.
Suggested patch
@@ - <a href="#" - class="filter-option <?= $isActive ? 'active' : '' ?>" + <a href="#" + class="filter-option <?= $isActive ? 'active' : '' ?>" + data-media-filter="<?= htmlspecialchars((string) $tmValue, ENT_QUOTES, 'UTF-8') ?>" onclick="updateFilter('tipo_media', <?= htmlspecialchars(json_encode((string) $tmValue, JSON_HEX_TAG | JSON_HEX_APOS), ENT_QUOTES, 'UTF-8') ?>); return false;"> @@ $i18nTranslations = [ @@ 'errore_caricamento' => __('Errore nel caricamento. Riprova.') ]; $i18nJson = json_encode($i18nTranslations, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG); +$mediaTypeLabels = ['' => __('Tutti i media')]; +foreach (\App\Support\MediaLabels::allTypes() as $tmValue => $tmMeta) { + $mediaTypeLabels[(string) $tmValue] = __($tmMeta['label']); +} +$mediaTypeLabelsJson = json_encode($mediaTypeLabels, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG); @@ const i18n = {$i18nJson}; +const MEDIA_TYPE_LABELS = {$mediaTypeLabelsJson}; @@ syncAvailabilityActiveState(); + syncMediaTypeActiveState(); }); @@ if (key === 'disponibilita') { syncAvailabilityActiveState(); + } else if (key === 'tipo_media') { + syncMediaTypeActiveState(); } @@ +function syncMediaTypeActiveState() { + const currentValue = currentFilters.tipo_media || ''; + document.querySelectorAll('[data-media-filter]').forEach((option) => { + option.classList.toggle('active', (option.dataset.mediaFilter || '') === currentValue); + }); +} + function updateActiveFiltersDisplay() { @@ - } else if (filterKey === 'genere_id') { + } else if (filterKey === 'genere_id') { displayValue = currentGenreName || value; + } else if (filterKey === 'tipo_media') { + displayValue = MEDIA_TYPE_LABELS[value] || value; }Also applies to: 1460-1460, 1622-1622
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Views/frontend/catalog.php` around lines 1302 - 1326, The tipo_media UI is added server-side but not fully wired to the client: ensure the client-side active-state sync mirrors how availability works by having updateFilter('tipo_media', ...) trigger the same JS path that sets/unsets the .filter-option.active class and updates the active-filter tags; use the user-facing labels from MediaLabels::allTypes() (the $tipoFilters entries / $tmInfo['label']) when rendering active-filter tags instead of raw enum keys so displayed tags show localized labels, and ensure any JS that reads currentTipo (filters['tipo_media']) uses the stringified values you json_encode in the onclick to match server/state comparison.app/Controllers/CsvImportController.php (1)
773-775:⚠️ Potential issue | 🟠 MajorNormalize
tipo_mediabefore binding it to the enum column.
parseCsvRow()now forwards the raw CSV cell unchanged, and both new SQL branches persist that value directly. Inputs likeCD,Vinyl,Audiobook, or localized labels won't match thelibri.tipo_mediaenum, so the import can fail or store an unintended value. Normalize/validate the CSV value to the canonical enum (libro,disco,audiolibro,dvd,altro) before it reaches the query.Also applies to: 1269-1292, 1360-1390
app/Views/frontend/book-detail.php (1)
2192-2193:⚠️ Potential issue | 🟡 MinorUse each related card's media flag for the fallback author label.
This still keys off the parent page's
$isMusic, so a mixed related-items section can showArtista sconosciutoon a book card orAutore sconosciutoon a music card.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Views/frontend/book-detail.php` around lines 2192 - 2193, The fallback author label currently uses the parent $isMusic flag; change it to use the related item’s own media flag by switching the ternary to check $related's isMusic flag first (e.g. $related['isMusic'] ?? $isMusic) so the fallback text for <?= 'autori' ?> is chosen per related card rather than the parent page.storage/plugins/discogs/views/settings.php (1)
138-142:⚠️ Potential issue | 🟡 MinorUse the named plugins route for this backlink.
This link is still bound to
/admin/plugins, so it will drift if the admin route is renamed or localized. Use the existing named route helper and escape that result in thehref.As per coding guidelines "Never use hardcoded routes. Use
route_path('key')orRouteTranslator::route('key')instead of hardcoded paths like/accedior/login."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@storage/plugins/discogs/views/settings.php` around lines 138 - 142, Replace the hardcoded url('/admin/plugins') used in the anchor href with the named-route helper and escape its result; specifically, in the anchor where htmlspecialchars(url('/admin/plugins')...) is used, call htmlspecialchars(route_path('key'), ENT_QUOTES, 'UTF-8') (or RouteTranslator::route('key') if your app uses that), keeping the same ENT_QUOTES and UTF-8 arguments so the backlink uses the named route instead of a hardcoded path.app/Views/libri/index.php (1)
688-697:⚠️ Potential issue | 🟡 MinorUse the localized media label here, not the enum key.
The dropdown is already powered by
MediaLabels::allTypes(), but the icon tooltip and the active-filter chip still show raw values likediscoandaudiolibro, so this UI stays partially untranslated. Reuse the same label map in both places.Also applies to: 885-886
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Views/libri/index.php` around lines 688 - 697, The column render currently uses the raw enum key (data) for the icon tooltip; replace that with the localized label from the same mapping used for the dropdown (use MediaLabels::allTypes() or the label map it returns) so the tooltip shows the translated media name; update the render in the 'tipo_media' column (and the similar code at the other occurrence around the active-filter chip) to look up the label by key (fallback to a default like 'libro') and use that value in the title attribute instead of the raw key.tests/discogs-import.spec.js (4)
50-53:⚠️ Potential issue | 🟠 MajorTeardown still misses the
isbn13persistence path.Tests 3, 5, and 6 explicitly accept records saved through
isbn13, butafterAll()only deletes byean. A previous run can therefore leave the imported row behind and make later runs pass against stale data.Suggested fix
- try { dbExec("DELETE FROM libri WHERE ean = '0720642442524' AND deleted_at IS NULL"); } catch {} + try { + dbExec( + "DELETE FROM libri WHERE (ean = '0720642442524' OR isbn13 LIKE '%720642442524%') AND deleted_at IS NULL" + ); + } catch {}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/discogs-import.spec.js` around lines 50 - 53, The teardown in test.afterAll only deletes by ean and misses records persisted via isbn13; update the cleanup to also remove rows where isbn13 = '0720642442524' (either add a second dbExec call deleting where isbn13 matches or expand the SQL to delete where ean = '0720642442524' OR isbn13 = '0720642442524'), keep the try/catch around dbExec, and still await context?.close(); reference test.afterAll, dbExec, isbn13, ean, and context.close when making the change.
56-67:⚠️ Potential issue | 🟠 MajorThe main import suite still turns product failures into skips.
After the env-var gate, an inactive plugin, missing import button, or empty
titolomeans the Discogs flow failed. Usingtest.skip()here lets the “full scraping flow” suite go green without actually exercising the import path.Suggested fix
- test.skip(parseInt(isActiveNow) === 0, 'Discogs plugin could not be activated'); + expect(parseInt(isActiveNow)).toBeGreaterThan(0); @@ - if (!await importBtn.isVisible({ timeout: 5000 }).catch(() => false)) { - test.skip(true, 'Import button not visible — no scraping plugin active'); - return; - } + await expect(importBtn).toBeVisible({ timeout: 5000 }); @@ - if (titleValue === '') { - // Scraping may have failed (rate limit, network). Check if any source populated data - test.skip(true, 'Scraping did not return data (possibly rate limited)'); - return; - } + expect(titleValue, `Discogs scrape returned no data for barcode ${TEST_BARCODE}`).not.toBe('');Also applies to: 77-97
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/discogs-import.spec.js` around lines 56 - 67, The current test turns real failures into skips by calling test.skip(...) when the Discogs plugin isn't active or import prerequisites are missing; change this to cause a test failure instead: replace the test.skip(...) calls with an explicit failing assertion or throw (e.g., use expect(parseInt(isActiveNow)).toBeGreaterThan(0) or throw new Error('Discogs plugin could not be activated')) so the suite fails when the plugin/import button/titolo check fails; apply the same change for the similar block referenced around lines 77-97 and keep references to dbQuery, page.goto and the surrounding test('1. Verify Discogs plugin is active', ...) block to locate the code.
155-164:⚠️ Potential issue | 🟠 MajorMissing persisted row should fail this suite, not skip it.
If neither lookup finds the saved album, the save step regressed.
test.skip()hides that failure and lets the remaining checks continue with invalid state.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/discogs-import.spec.js` around lines 155 - 164, The test currently calls test.skip when dbQuery returns an empty result (bookByIsbn === '') which hides the failure; change this to actively fail the test instead—replace test.skip(bookByIsbn === '', 'CD not found in database') with an explicit failure (for example throw new Error('CD not found in database') or use your test framework's fail API) so that when dbQuery (dbQuery / bookByIsbn) yields no row the spec fails immediately and subsequent assertions (expect(...).toContain) do not run on invalid state.
180-188:⚠️ Potential issue | 🟠 MajorDon’t guard the only label assertion behind a
formatoheuristic.A broken format mapping just bypasses the check, so this test never proves the saved record is treated as music. Assert
tipo_media === 'disco'(or assert the persistedformatoseparately) and keep the label expectation mandatory for that branch.Suggested fix
- const formato = dbQuery(`SELECT formato FROM libri WHERE id = ${bookId}`); - - if (formato && ['cd_audio', 'vinile', 'cd', 'vinyl'].some(f => formato.toLowerCase().includes(f))) { - // Music labels should be active - const hasMusicLabel = content.includes('Etichetta') || content.includes('Label') || - content.includes('Anno di Uscita') || content.includes('Release Year'); - expect(hasMusicLabel).toBe(true); - } + const tipoMedia = dbQuery( + `SELECT tipo_media FROM libri WHERE id = ${bookId} AND deleted_at IS NULL LIMIT 1` + ); + expect(tipoMedia).toBe('disco'); + + const hasMusicLabel = content.includes('Etichetta') || content.includes('Label') || + content.includes('Anno di Uscita') || content.includes('Release Year'); + expect(hasMusicLabel).toBe(true);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/discogs-import.spec.js` around lines 180 - 188, The test currently skips the music-label assertion based on a fragile formato heuristic; instead query the persisted tipo_media for the record (use dbQuery to SELECT tipo_media FROM libri WHERE id = ${bookId}) and assert expect(tipo_media).toBe('disco') (or separately assert the persisted formato equals a music format) and then always assert the label fields are present by checking content (hasMusicLabel) with expect(hasMusicLabel).toBe(true); keep references to the existing formato variable only if you add a separate assertion for the stored formato, and use the same bookId and content variables already in the test.tests/discogs-plugin.spec.js (2)
183-202:⚠️ Potential issue | 🟠 MajorThis “frontend” check still only exercises the admin template.
Requesting
${BASE}/admin/libri/${bookId}will not catch regressions in the public detail page or the Barcode-vs-ISBN label logic there. Resolve the public book URL and assert the frontend HTML directly.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/discogs-plugin.spec.js` around lines 183 - 202, Test "6. Frontend: music book shows Barcode instead of ISBN-13" is hitting the admin template (`page.request.get(${BASE}/admin/libri/${bookId})`) so it doesn't validate the public/front-end labeling; change the request to the public book detail URL (use the public route pattern used elsewhere, e.g. `${BASE}/libri/${bookId}` or the app's public resolver) and re-run the same HTML checks against that response (inspect the returned HTML for 'Barcode' or music-specific labels) so the spec exercises the public detail page rather than the admin page.
91-109:⚠️ Potential issue | 🟠 MajorScope install/activate/settings actions to Discogs and fail when its card is missing.
button:has-text("Installa").first()can act on a different plugin, and Test 3 silently passes if the Discogs settings link never appears. Root those locators in the Discogs card and add explicit assertions for card visibility, activation, and token-field visibility before continuing.Also applies to: 121-137
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/discogs-plugin.spec.js` around lines 91 - 109, The test currently uses global button locators that may target other plugins; update code to scope install/activate/settings actions to the Discogs card by deriving installBtn, activateBtn, and settings link from the discogsCard locator (discogsCard.locator(...)) and add explicit assertions that discogsCard.isVisible() throws/returns false -> fail the test, that activation actually occurred (assert activateBtn becomes hidden or card shows active state) and that the token field (e.g., the Discogs token input) is visible before proceeding; ensure any .isVisible({timeout}) checks use assertions (not silent .catch(() => false)) so the test fails fast if the Discogs card is missing or actions do not succeed.storage/plugins/discogs/DiscogsPlugin.php (2)
696-739:⚠️ Potential issue | 🟠 Major
api_tokenis still split across raw SQL and the encrypted settings API.
getSetting()readsplugin_settingsverbatim while the controller save path stores encryptedENC:values, so authenticated requests can end up sending ciphertext as the Discogs token.saveSettings()then writes plaintext back into the same table, which regresses secret handling in the opposite direction. Route both reads and writes through the samePluginManagersettings API.Also applies to: 761-820
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@storage/plugins/discogs/DiscogsPlugin.php` around lines 696 - 739, getSetting() and saveSettings() are directly reading/writing plugin_settings (returning/storing raw or ENC: values) causing plaintext/ciphertext mismatch for api_token; switch both read and write paths to use the centralized PluginManager settings API instead of direct SQL. Locate getSetting() and saveSettings() in DiscogsPlugin and replace the direct DB SELECT/UPDATE logic that touches plugin_settings and pluginId with calls to the PluginManager methods that retrieve and store plugin settings (the encrypted-aware getters/setters provided by the PluginManager), ensuring you pass the plugin identifier (this->pluginId or resolved name) and the 'api_token' key so the API handles encryption/decryption consistently. Ensure you remove the direct SQL access (prepare/execute/bind_param) for those keys and rely solely on the PluginManager for both reads and writes.
223-231:⚠️ Potential issue | 🟠 MajorThe promised title fallback still never runs.
Line 230 returns immediately on a barcode miss, so releases without Discogs barcode metadata are unreachable even though this method is documented as “barcode, then query search.” If title/artist are required for the fallback, thread them into this hook or move the fallback to a stage that has them before returning.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@storage/plugins/discogs/DiscogsPlugin.php` around lines 223 - 231, The method currently returns $currentResult immediately when the barcode search yields no results (see $searchResult check), preventing the documented "barcode, then query search" fallback from ever running; instead, remove the early return and invoke the fallback query using title/artist (pull them from $currentResult if available) or call a dedicated fallback helper (e.g., a new or existing method like searchByTitleArtist) which builds a non-barcode $searchUrl and calls apiRequest with the same $token; if title/artist are not present in this scope, thread them into this hook (pass them into the method) or move the fallback invocation to the earlier stage that has those fields before returning.
🧹 Nitpick comments (1)
app/Controllers/ScrapeController.php (1)
776-780: Avoid baking a scraper name into core normalization.This branch is really about EAN-only music payloads, not Discogs specifically. Keying it off
source === 'discogs'means the next non-book scraper will need another core special-case instead of using a provider-agnostic signal.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Controllers/ScrapeController.php` around lines 776 - 780, The normalization branch is keyed to ($data['source'] ?? '') === 'discogs', which bakes a provider name into core logic; instead detect EAN-only music payloads generically (e.g., check for presence of a barcode/EAN and absence of isbn13/isbn10 or a provider-agnostic flag). Update the ScrapeController.php branch that currently returns $data for Discogs to something like: if (isset($data['barcode']) && empty($data['isbn13']) && empty($data['isbn10'])) return $data; or use a new payload field like is_ean_only set by scrapers, and reference the existing variables $data['source'], $data['barcode'], $data['isbn13'], and $data['isbn10'] when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/Controllers/FrontendController.php`:
- Around line 900-904: Guard the filter addition so we only add the
"l.tipo_media" condition when the column actually exists: before pushing to
conditions[], params[] and appending to types, check schema availability for the
column (e.g. use your DB schema helper like hasColumn('lotes','tipo_media') or
query information_schema) and only run the existing block when that check passes
and filters['tipo_media'] is not empty; update the code paths around
filters['tipo_media'], conditions[], params[] and types to be conditional on the
schema check to avoid "Unknown column 'l.tipo_media'" errors.
In `@app/Controllers/LibraryThingImportController.php`:
- Around line 914-915: parseLibraryThingRow() currently sets
$result['tipo_media'] via \App\Support\MediaLabels::inferTipoMedia(...)
unconditionally, causing updates to overwrite an existing tipo_media (e.g.,
disco/dvd) with a default like libro; change the logic so parseLibraryThingRow()
leaves $result['tipo_media'] null unless inferTipoMedia(...) returns a
recognized/valid value, and update the update branches (the code paths that
write back $result['tipo_media'] on matched imports) to skip writing tipo_media
when it is null; only apply the default inference (call inferTipoMedia and set
tipo_media if null) in the insert/new-record path so new imports get a default
but updates do not overwrite an existing tipo_media.
In `@app/Models/BookRepository.php`:
- Around line 303-306: The code currently sets tipo_media only from
$data['tipo_media'] (and similar for scraped_tipo_media), causing records that
only provide formato (as in the Discogs flow) to default to 'libro'; update the
logic in the blocks that call hasColumn('tipo_media') and normalizeEnumValue to
first check for a canonical tipo_media, then fall back to $data['formato'] (map
known formato values like CD/LP/cassette to their corresponding tipo_media)
before finally defaulting to 'libro'; apply the same fallback change to the
other occurrence that handles scraped_tipo_media so both paths use formato when
tipo_media is absent, using the existing normalizeEnumValue and addField
functions to validate and set the field.
In `@app/Views/frontend/book-detail.php`:
- Around line 164-166: The JSON-LD currently sets `@type` via
\App\Support\MediaLabels::schemaOrgType($resolvedTipoMedia) but always builds
$bookSchema with book-specific properties (bookEdition, isbn, numberOfPages);
change schema construction to branch on the resolved type (use the value
returned by MediaLabels::schemaOrgType($resolvedTipoMedia) or the
$resolvedTipoMedia variable) and create a media-specific schema object for
non-Book types (e.g., MusicRecording, Movie) that omits book-only fields and
adds appropriate properties for that media; update code around the $bookSchema
variable so you build and output the correct schema shape per type instead of
only switching `@type`.
In `@installer/database/migrations/migrate_0.5.4.sql`:
- Around line 27-38: The migration's UPDATEs use exact LOWER(formato) IN (...)
matching which misses values matched at runtime by
App\Support\MediaLabels::inferTipoMedia() (which uses substring checks); update
the three UPDATE statements in migrate_0.5.4.sql to use the same
substring/ILIKE/REGEXP rules as inferTipoMedia() (e.g., check LOWER(formato)
LIKE '%cd%' OR '%vinyl%' OR '%lp%' OR '%cassette%' for disco, patterns for
'audiolibro' like '%audio%' or '%audiobook%', and patterns for 'dvd' like
'%dvd%' OR '%blu%' OR '%blu-ray%'/'%blu_ray%') so existing rows get the same
tipo_media classification as the runtime MediaLabels::inferTipoMedia() logic.
In `@storage/plugins/discogs/DiscogsPlugin.php`:
- Around line 277-291: The current early return in DiscogsPlugin (the check if
(($data['source'] ?? '') !== 'discogs') in DiscogsPlugin.php) prevents cover
enrichment when a discogs_id exists but source isn't 'discogs'; change the
gating logic to base enrichment on the presence of discogs_id instead of source:
remove or bypass the source check and allow the subsequent code that reads
$discogsId = $data['discogs_id'] ?? null and calls
$this->getSetting('api_token'), self::API_BASE . '/releases/' . (int)$discogsId
and $this->apiRequest(...) to run whenever discogs_id is present (null check
remains), so covers are backfilled even when BookDataMerger::merge() left source
unchanged.
In `@storage/plugins/discogs/plugin.json`:
- Around line 11-12: The manifest declares requires_app and max_app_version but
PluginManager only validates requires_php, so either add app-version validation
to PluginManager or remove those fields from the manifest; to fix, update
PluginManager to read the manifest's requires_app and max_app_version, parse
semantic versions, fetch the running application's version (use the existing
app-version source in your app, e.g. an Application.version or APP_VERSION
constant), then enforce that runningVersion >= requires_app and runningVersion
<= max_app_version and fail install/activation with a clear error, or if you
prefer the simpler change, remove the requires_app and max_app_version entries
from the plugin manifest to avoid promising unsupported compatibility.
In `@tests/discogs-advanced.spec.js`:
- Around line 116-139: The test "3. Schema.org JSON-LD type is MusicAlbum for
disco" currently only checks storage via apiResp/musicBook and DB queries
(musicBook, musicBookId, bookBookId); update it to actually fetch and assert the
frontend JSON-LD `@type`: navigate to the public detail page for the found
musicBook (or construct its public URL) and parse the <script
type="application/ld+json"> payload, then assert that the parsed JSON's "@type"
equals "MusicAlbum" for the disco record and "Book" for the libro record;
alternatively, if you intend to keep it as a storage-level test, rename the test
title to reflect that it only validates tipo_media in API/DB.
- Around line 52-60: The teardown is only deleting seeded rows by their original
titolo suffix so if a test (Test 5) renames a row the cleanup can miss it;
update the teardown (afterAll) to remove fixtures by stable identifiers instead
of mutable titles—e.g., call dbExec DELETE statements targeting the seeded ean
'1234567890123' and isbn13 '9781234567897' (and any other fixed identifiers used
in the inserts) or use a robust predicate like WHERE titolo LIKE 'E2E_ADV_%'
combined with OR ean=... OR isbn13=...; apply the same change for all teardown
spots related to the inserts created by RUN_ID (the dbExec insert sites and the
afterAll reset) so stale rows cannot survive a failed reset.
---
Outside diff comments:
In `@app/Controllers/CsvImportController.php`:
- Around line 1231-1256: The UPDATE statement prepared in CsvImportController
(the $stmt prepared for table libri) must include the soft-delete guard so we
only update non-deleted rows; modify the WHERE clause from "WHERE id = ?" to
"WHERE id = ? AND deleted_at IS NULL" (or equivalent parameterized condition) so
a concurrent soft-delete won’t allow updates to archived rows; ensure the change
is applied to the prepared SQL used by the update path that complements
findExistingBook().
In `@app/Support/PluginManager.php`:
- Around line 45-53: The bundled "discogs" entry is being auto-enabled because
autoRegisterBundledPlugins() inserts entries with is_active = 1 and immediately
calls onActivate(); change the behavior so bundled optional plugins are
registered but not auto-activated: either remove 'discogs' from the
BUNDLED_PLUGINS constant and add it to a new BUNDLED_OPTIONAL_PLUGINS list, or
modify autoRegisterBundledPlugins() to insert with is_active = 0 and skip
calling onActivate() for items flagged optional (check for a new optionalPlugins
array or a lookup), keeping onActivate() calls only for truly required plugins;
update references to BUNDLED_PLUGINS, autoRegisterBundledPlugins(), is_active
and onActivate() accordingly.
In `@app/Support/Updater.php`:
- Around line 39-47: The rollback path doesn't snapshot storage/plugins so
updating bundled plugins (BUNDLED_PLUGINS used by updateBundledPlugins() called
from installUpdate()) can leave plugins upgraded when core is restored; modify
backupAppFiles() and restoreAppFiles() to include the storage/plugins directory
(or alternatively make updateBundledPlugins() write plugin updates to a
temporary location and atomically move them only after install/migrations
succeed), ensuring any plugin paths touched by BUNDLED_PLUGINS are backed up and
restored on failure.
---
Duplicate comments:
In `@app/Views/frontend/book-detail.php`:
- Around line 2192-2193: The fallback author label currently uses the parent
$isMusic flag; change it to use the related item’s own media flag by switching
the ternary to check $related's isMusic flag first (e.g. $related['isMusic'] ??
$isMusic) so the fallback text for <?= 'autori' ?> is chosen per related card
rather than the parent page.
In `@app/Views/frontend/catalog.php`:
- Around line 1302-1326: The tipo_media UI is added server-side but not fully
wired to the client: ensure the client-side active-state sync mirrors how
availability works by having updateFilter('tipo_media', ...) trigger the same JS
path that sets/unsets the .filter-option.active class and updates the
active-filter tags; use the user-facing labels from MediaLabels::allTypes() (the
$tipoFilters entries / $tmInfo['label']) when rendering active-filter tags
instead of raw enum keys so displayed tags show localized labels, and ensure any
JS that reads currentTipo (filters['tipo_media']) uses the stringified values
you json_encode in the onclick to match server/state comparison.
In `@app/Views/libri/index.php`:
- Around line 688-697: The column render currently uses the raw enum key (data)
for the icon tooltip; replace that with the localized label from the same
mapping used for the dropdown (use MediaLabels::allTypes() or the label map it
returns) so the tooltip shows the translated media name; update the render in
the 'tipo_media' column (and the similar code at the other occurrence around the
active-filter chip) to look up the label by key (fallback to a default like
'libro') and use that value in the title attribute instead of the raw key.
In `@app/Views/libri/partials/book_form.php`:
- Around line 3615-3624: The code sets the select `#tipo_media` directly from
data.tipo_media which may contain variants (e.g., "CD", "vinyl") and leaves the
select unset; instead whitelist and normalize the incoming value against the
canonical keys from MediaLabels::allTypes(): create a mapping of allowed
keys/aliases (server-side output of MediaLabels::allTypes() into a JS object or
array), normalize data.tipo_media (trim, lower/upper-case as needed), look up
the normalized value in that whitelist (or map aliases to canonical keys) and
only assign tipoMediaSelect.value when a match exists; keep the try/catch but
log or silently skip when no valid match is found to avoid inconsistent form
state.
In `@storage/plugins/discogs/DiscogsPlugin.php`:
- Around line 696-739: getSetting() and saveSettings() are directly
reading/writing plugin_settings (returning/storing raw or ENC: values) causing
plaintext/ciphertext mismatch for api_token; switch both read and write paths to
use the centralized PluginManager settings API instead of direct SQL. Locate
getSetting() and saveSettings() in DiscogsPlugin and replace the direct DB
SELECT/UPDATE logic that touches plugin_settings and pluginId with calls to the
PluginManager methods that retrieve and store plugin settings (the
encrypted-aware getters/setters provided by the PluginManager), ensuring you
pass the plugin identifier (this->pluginId or resolved name) and the 'api_token'
key so the API handles encryption/decryption consistently. Ensure you remove the
direct SQL access (prepare/execute/bind_param) for those keys and rely solely on
the PluginManager for both reads and writes.
- Around line 223-231: The method currently returns $currentResult immediately
when the barcode search yields no results (see $searchResult check), preventing
the documented "barcode, then query search" fallback from ever running; instead,
remove the early return and invoke the fallback query using title/artist (pull
them from $currentResult if available) or call a dedicated fallback helper
(e.g., a new or existing method like searchByTitleArtist) which builds a
non-barcode $searchUrl and calls apiRequest with the same $token; if
title/artist are not present in this scope, thread them into this hook (pass
them into the method) or move the fallback invocation to the earlier stage that
has those fields before returning.
In `@storage/plugins/discogs/views/settings.php`:
- Around line 138-142: Replace the hardcoded url('/admin/plugins') used in the
anchor href with the named-route helper and escape its result; specifically, in
the anchor where htmlspecialchars(url('/admin/plugins')...) is used, call
htmlspecialchars(route_path('key'), ENT_QUOTES, 'UTF-8') (or
RouteTranslator::route('key') if your app uses that), keeping the same
ENT_QUOTES and UTF-8 arguments so the backlink uses the named route instead of a
hardcoded path.
In `@tests/discogs-import.spec.js`:
- Around line 50-53: The teardown in test.afterAll only deletes by ean and
misses records persisted via isbn13; update the cleanup to also remove rows
where isbn13 = '0720642442524' (either add a second dbExec call deleting where
isbn13 matches or expand the SQL to delete where ean = '0720642442524' OR isbn13
= '0720642442524'), keep the try/catch around dbExec, and still await
context?.close(); reference test.afterAll, dbExec, isbn13, ean, and
context.close when making the change.
- Around line 56-67: The current test turns real failures into skips by calling
test.skip(...) when the Discogs plugin isn't active or import prerequisites are
missing; change this to cause a test failure instead: replace the test.skip(...)
calls with an explicit failing assertion or throw (e.g., use
expect(parseInt(isActiveNow)).toBeGreaterThan(0) or throw new Error('Discogs
plugin could not be activated')) so the suite fails when the plugin/import
button/titolo check fails; apply the same change for the similar block
referenced around lines 77-97 and keep references to dbQuery, page.goto and the
surrounding test('1. Verify Discogs plugin is active', ...) block to locate the
code.
- Around line 155-164: The test currently calls test.skip when dbQuery returns
an empty result (bookByIsbn === '') which hides the failure; change this to
actively fail the test instead—replace test.skip(bookByIsbn === '', 'CD not
found in database') with an explicit failure (for example throw new Error('CD
not found in database') or use your test framework's fail API) so that when
dbQuery (dbQuery / bookByIsbn) yields no row the spec fails immediately and
subsequent assertions (expect(...).toContain) do not run on invalid state.
- Around line 180-188: The test currently skips the music-label assertion based
on a fragile formato heuristic; instead query the persisted tipo_media for the
record (use dbQuery to SELECT tipo_media FROM libri WHERE id = ${bookId}) and
assert expect(tipo_media).toBe('disco') (or separately assert the persisted
formato equals a music format) and then always assert the label fields are
present by checking content (hasMusicLabel) with
expect(hasMusicLabel).toBe(true); keep references to the existing formato
variable only if you add a separate assertion for the stored formato, and use
the same bookId and content variables already in the test.
In `@tests/discogs-plugin.spec.js`:
- Around line 183-202: Test "6. Frontend: music book shows Barcode instead of
ISBN-13" is hitting the admin template
(`page.request.get(${BASE}/admin/libri/${bookId})`) so it doesn't validate the
public/front-end labeling; change the request to the public book detail URL (use
the public route pattern used elsewhere, e.g. `${BASE}/libri/${bookId}` or the
app's public resolver) and re-run the same HTML checks against that response
(inspect the returned HTML for 'Barcode' or music-specific labels) so the spec
exercises the public detail page rather than the admin page.
- Around line 91-109: The test currently uses global button locators that may
target other plugins; update code to scope install/activate/settings actions to
the Discogs card by deriving installBtn, activateBtn, and settings link from the
discogsCard locator (discogsCard.locator(...)) and add explicit assertions that
discogsCard.isVisible() throws/returns false -> fail the test, that activation
actually occurred (assert activateBtn becomes hidden or card shows active state)
and that the token field (e.g., the Discogs token input) is visible before
proceeding; ensure any .isVisible({timeout}) checks use assertions (not silent
.catch(() => false)) so the test fails fast if the Discogs card is missing or
actions do not succeed.
---
Nitpick comments:
In `@app/Controllers/ScrapeController.php`:
- Around line 776-780: The normalization branch is keyed to ($data['source'] ??
'') === 'discogs', which bakes a provider name into core logic; instead detect
EAN-only music payloads generically (e.g., check for presence of a barcode/EAN
and absence of isbn13/isbn10 or a provider-agnostic flag). Update the
ScrapeController.php branch that currently returns $data for Discogs to
something like: if (isset($data['barcode']) && empty($data['isbn13']) &&
empty($data['isbn10'])) return $data; or use a new payload field like
is_ean_only set by scrapers, and reference the existing variables
$data['source'], $data['barcode'], $data['isbn13'], and $data['isbn10'] when
making the change.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: f2a2b3b5-5827-45bc-a23d-e5cedd7a20fa
📒 Files selected for processing (33)
.gitignoreapp/Controllers/CsvImportController.phpapp/Controllers/FrontendController.phpapp/Controllers/LibraryThingImportController.phpapp/Controllers/LibriApiController.phpapp/Controllers/LibriController.phpapp/Controllers/PluginController.phpapp/Controllers/PublicApiController.phpapp/Controllers/ScrapeController.phpapp/Models/BookRepository.phpapp/Support/MediaLabels.phpapp/Support/PluginManager.phpapp/Support/Updater.phpapp/Views/frontend/book-detail.phpapp/Views/frontend/catalog-grid.phpapp/Views/frontend/catalog.phpapp/Views/libri/index.phpapp/Views/libri/partials/book_form.phpapp/Views/libri/scheda_libro.phpinstaller/database/migrations/migrate_0.5.4.sqlinstaller/database/schema.sqllocale/de_DE.jsonlocale/en_US.jsonlocale/it_IT.jsonpinakes-v0.5.3.zip.sha256storage/plugins/discogs/DiscogsPlugin.phpstorage/plugins/discogs/README.mdstorage/plugins/discogs/plugin.jsonstorage/plugins/discogs/views/settings.phpstorage/plugins/discogs/wrapper.phptests/discogs-advanced.spec.jstests/discogs-import.spec.jstests/discogs-plugin.spec.js
| "requires_app": "0.5.0", | ||
| "max_app_version": "1.0.0", |
There was a problem hiding this comment.
Don't rely on requires_app / max_app_version as a compatibility guard yet.
PluginManager currently validates only requires_php and just persists requires_app, so these manifest entries won't actually block incompatible Pinakes versions from installing or activating this plugin. Either add the app-version checks in PluginManager or drop the unsupported compatibility promise from the manifest for now.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@storage/plugins/discogs/plugin.json` around lines 11 - 12, The manifest
declares requires_app and max_app_version but PluginManager only validates
requires_php, so either add app-version validation to PluginManager or remove
those fields from the manifest; to fix, update PluginManager to read the
manifest's requires_app and max_app_version, parse semantic versions, fetch the
running application's version (use the existing app-version source in your app,
e.g. an Application.version or APP_VERSION constant), then enforce that
runningVersion >= requires_app and runningVersion <= max_app_version and fail
install/activation with a clear error, or if you prefer the simpler change,
remove the requires_app and max_app_version entries from the plugin manifest to
avoid promising unsupported compatibility.
|
@CodeRabbit full review |
✅ Actions performedFull review triggered. |
Backend: - CsvImportController: soft-delete guard on all UPDATE libri - CSV parseCsvRow: normalize tipo_media through inferTipoMedia - FrontendController: hasColumn guard for tipo_media filter - LT import: tipo_media null on update (preserve existing), infer only on insert - BookRepository createBasic: fallback from formato when tipo_media absent - Migration: LIKE patterns instead of exact IN() for broader matching - Discogs enrichment: gate on discogs_id, not source Schema.org: - MusicAlbum: byArtist, recordLabel, numTracks, EAN identifier - Movie: director, productionCompany, EAN identifier - Audiobook: author, publisher, readBy (narrator), isbn - Book: full properties (author, translator, illustrator, editor, isbn, issn, pages, edition) Tests: - Cleanup by ean/isbn13 (stable identifiers, not mutable titles)
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 12
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
app/Support/Updater.php (1)
2227-2237:⚠️ Potential issue | 🟠 MajorGuard this recursive delete and stop on the first removal failure.
removeDirectoryTree()trusts whatever path it receives and hides everyunlink()/rmdir()failure. Since the caller now builds that path from raw entries inBundledPlugins::LIST(app/Support/BundledPlugins.php, Lines 1-19), a bad slug could escapestorage/plugins, and even ordinary delete failures would leave a mixed old/new plugin tree while the update continues. Please validate/canonicalize the plugin path before Line 2236 and make this helper abort instead of suppressing filesystem errors.Also applies to: 2244-2264
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Support/Updater.php` around lines 2227 - 2237, The delete step uses removeDirectoryTree() on paths built from BundledPlugins::LIST and currently suppresses failures; before calling removeDirectoryTree($targetPluginPath) in Updater.php validate and canonicalize the target path (resolve realpath or normalize and ensure it is strictly inside the expected storage/plugins directory) to prevent path traversal, and change the flow so removeDirectoryTree() returns/throws on first failure and the caller aborts the update (do not continue to copyDirectoryRecursive) when removal fails; update removeDirectoryTree() to surface unlink()/rmdir() errors (or return false) and make the loop in the plugin update block check that result and stop the update if it fails.
♻️ Duplicate comments (1)
app/Support/MediaLabels.php (1)
91-107:⚠️ Potential issue | 🟠 MajorMake explicit
tipo_mediaauthoritative inisMusic().Line 93 resolves the explicit media type, but Lines 97-105 still inspect
formatoand can turnaudiolibrorows oncd_audio/audiocassettaback into music. That brings back theArtista/Etichetta/Tracklistmislabeling for audiobooks on physical media.♻️ Minimal fix
public static function isMusic(?string $formato, ?string $tipoMedia = null): bool { - if (self::resolveTipoMedia($formato, $tipoMedia) === 'disco') { - return true; - } - - foreach (self::normalizedCandidates($formato) as $candidate) { - if (in_array($candidate, self::MUSIC_FORMATS, true)) { - return true; - } - - if (preg_match('/\b(?:music|musik)\b/i', $candidate) === 1) { - return true; - } + $normalizedTipoMedia = self::normalizeTipoMedia($tipoMedia); + if ($normalizedTipoMedia !== null) { + return $normalizedTipoMedia === 'disco'; } - return false; + return self::inferTipoMedia($formato) === 'disco'; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Support/MediaLabels.php` around lines 91 - 107, isMusic() currently falls back to inspecting formato even when an explicit tipo_media is provided via resolveTipoMedia(), which lets rows with tipo_media like "audiolibro" be misclassified as music; change isMusic() so that after calling resolveTipoMedia($formato, $tipoMedia) you treat any non-null resolved tipo as authoritative: if it equals 'disco' return true, otherwise return false and skip the normalizedCandidates()/MUSIC_FORMATS/preg_match checks; refer to isMusic(), resolveTipoMedia(), normalizedCandidates(), and MUSIC_FORMATS when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/Controllers/FrontendController.php`:
- Line 745: The 'tipo_media' entry currently passes the raw param through,
allowing arrays (e.g. ?tipo_media[]=music) to reach SQL bind code; change the
assignment for 'tipo_media' so it normalizes to a scalar string: if
$params['tipo_media'] is an array, take its first element, otherwise use the
value, then cast to string and trim() (falling back to empty string). Update the
array entry that currently reads 'tipo_media' => $params['tipo_media'] ?? '' to
perform this normalization so later code using l.tipo_media = ? always receives
a scalar.
In `@app/Controllers/PluginController.php`:
- Around line 219-220: Replace the 403+Location pair with a proper redirect: if
you intend to move unauthorized users to the dashboard, return a 302 (or 303)
redirect instead of 403 and build the URL with the named-route helper (e.g. use
route_path('admin.dashboard') or RouteTranslator::route('admin.dashboard'))
rather than the hardcoded '/admin/dashboard'; alternatively, if you intend to
deny access, keep the 403 but remove the Location header (i.e. return
$response->withStatus(403) only). Apply this change where the $_SESSION['user']
admin check and the response->withStatus(...)->withHeader('Location', ...) call
occur.
In `@app/Models/BookRepository.php`:
- Around line 1060-1068: The code in BookRepository that sets tipo_media
currently defaults to 'libro' even when no media-related input was provided;
update the conditional that wraps the resolve/normalize block (the section
handling tipo_media, using MediaLabels::resolveTipoMedia and normalizeEnumValue)
to first check that at least one media signal key exists in the incoming $data
(e.g., 'formato', 'scraped_formato', or 'scraped_tipo_media') before calling
resolveTipoMedia and applying the default; this ensures updateOptionals() won't
silently overwrite existing tipo_media values when the payload contains no media
fields.
In `@app/Support/Updater.php`:
- Around line 2236-2237: Currently the updater deletes the live plugin dir then
copies directly with removeDirectoryTree($targetPluginPath) followed by
copyDirectoryRecursive($sourcePluginPath, $targetPluginPath), which can leave
storage/plugins/<slug> missing if copy fails; change the flow to stage the new
plugin tree into a temp directory (e.g. $targetPluginPath . '.tmp' or use
sys_get_temp_dir()), perform copyDirectoryRecursive($sourcePluginPath,
$tempPath), verify the copy succeeded, then atomically swap by renaming/removing
the old $targetPluginPath and renaming $tempPath to $targetPluginPath (or first
back up the original into the existing rollback set and include the plugin path
in the backup/restore logic used in the restore routine), ensuring
removeDirectoryTree is only called after a successful staged copy and that
failures leave the original directory intact.
In `@app/Views/admin/plugins.php`:
- Line 300: The link is building a hardcoded settings URL with
url('/admin/plugins/' . (int) $plugin['id'] . '/settings'); replace this with
the named route helper (e.g. route_path('admin.plugins.settings', ['id' => (int)
$plugin['id']]) or RouteTranslator::route('admin.plugins.settings', ['id' =>
(int) $plugin['id']])) and keep the outer htmlspecialchars(..., ENT_QUOTES,
'UTF-8') call; ensure you pass the plugin id as a parameter to the route helper
so the generated URL matches the previous path.
In `@installer/database/migrations/migrate_0.5.4.sql`:
- Around line 15-47: Migration currently creates idx_libri_tipo_media_deleted_at
with columns (tipo_media, deleted_at) then drops idx_libri_deleted_at, which
breaks queries that filter first on deleted_at; change the new composite index
creation to use (deleted_at, tipo_media) instead of (tipo_media, deleted_at) so
it can serve WHERE deleted_at IS NULL queries before dropping
idx_libri_deleted_at, keeping the conditional CREATE logic for
idx_libri_tipo_media_deleted_at and leaving the DROP logic for
idx_libri_deleted_at and idx_libri_tipo_media unchanged.
In `@installer/database/schema.sql`:
- Line 421: The composite index KEY `idx_libri_tipo_media_deleted_at` on table
`libri` is ordered (`tipo_media`,`deleted_at`) which won't support queries that
filter only on `deleted_at IS NULL`; change the index to have `deleted_at` as
the leading column so it becomes (`deleted_at`,`tipo_media`) to preserve
soft-delete coverage while keeping the media filter. Update the index definition
for `idx_libri_tipo_media_deleted_at` accordingly (drop/recreate or alter the
index) so queries filtering on deleted_at alone use the index.
In `@storage/plugins/discogs/DiscogsPlugin.php`:
- Around line 112-133: Wrap the deletion and re-insertion of hooks in a DB
transaction so the hook set is registered atomically: call
$this->db->begin_transaction() before $this->deleteHooks() and the foreach that
uses $this->db->prepare(), and on any prepare or execute error call
$this->db->rollback() (logging the error) and abort activation; only call
$this->db->commit() after all bind_param/execute calls succeed; ensure you still
close statements ($stmt->close()) on both success and error paths so resources
are released.
- Around line 235-249: The code currently uses the original $isbn as the EAN
even when a title/artist fallback (searchDiscogsByTitleArtist) matched—prevent
this by not carrying the unvalidated $isbn into persistent EAN fields when a
non-barcode fallback was used; modify the branches that call mergeBookData after
searchDiscogsByTitleArtist (and the similar MusicBrainz fallback) so they either
(a) pass a null/empty EAN to mergeBookData or (b) set a flag indicating
"unvalidated identifier" and ensure the persistence logic (where EAN is written,
e.g., the code path that uses $isbn to set ean) only writes EAN when it was
obtained/validated from the release (e.g., from
fetchDiscogsReleaseFromSearchResult) rather than reusing the original $isbn;
update searchDiscogsByTitleArtist / mergeBookData call sites accordingly to
avoid persisting an unverified barcode.
In `@tests/discogs-advanced.spec.js`:
- Around line 18-19: Replace the hardcoded fixtures SEEDED_MUSIC_EAN and
SEEDED_BOOK_ISBN with unique generated identifiers to avoid cross-test
collisions: update where SEEDED_MUSIC_EAN and SEEDED_BOOK_ISBN are defined/used
in tests/discogs-advanced.spec.js to call the unique generators (e.g.,
libri.ean() for EANs and libri.isbn13() for ISBNs or a test helper that returns
a fresh value per test) and propagate those generated values into the
insert/cleanup code and assertions so each test run uses its own identifiers
instead of the fixed '1234567890123' and '9781234567897'.
In `@tests/discogs-import.spec.js`:
- Around line 50-53: The test currently relies on a shared barcode to assert and
clean up which can match unrelated rows; instead capture the created record's id
from the admin create response (the /admin/libri/<id> response at the create
step currently around Lines 137-140), store it in a variable (e.g., createdId)
and use that id for the DB assertion and for teardown cleanup (replace dbExec
DELETE WHERE ean/isbn13 checks with DELETE WHERE id = createdId and assert using
SELECT ... WHERE id = createdId). Update any other spots in the same test (Lines
~143-159) that reference the barcode to use the saved id variable so assertions
and cleanup target the exact created row.
In `@tests/multisource-scraping.spec.js`:
- Around line 114-118: Test suite is collecting created IDs (manualBookId,
importedBookId, manualDiscId, importedDisc1Id, importedDisc2Id) but never
deletes the rows in afterAll; update the afterAll block to delete the created
RUN_TAG/catalog rows using those IDs (call the existing cleanup/delete helpers
or DB methods used elsewhere for removing books/discs/tags) so the suite removes
the records it created and prevents RUN_TAG pollution (ensure the afterAll
references the same variables: manualBookId, importedBookId, manualDiscId,
importedDisc1Id, importedDisc2Id).
---
Outside diff comments:
In `@app/Support/Updater.php`:
- Around line 2227-2237: The delete step uses removeDirectoryTree() on paths
built from BundledPlugins::LIST and currently suppresses failures; before
calling removeDirectoryTree($targetPluginPath) in Updater.php validate and
canonicalize the target path (resolve realpath or normalize and ensure it is
strictly inside the expected storage/plugins directory) to prevent path
traversal, and change the flow so removeDirectoryTree() returns/throws on first
failure and the caller aborts the update (do not continue to
copyDirectoryRecursive) when removal fails; update removeDirectoryTree() to
surface unlink()/rmdir() errors (or return false) and make the loop in the
plugin update block check that result and stop the update if it fails.
---
Duplicate comments:
In `@app/Support/MediaLabels.php`:
- Around line 91-107: isMusic() currently falls back to inspecting formato even
when an explicit tipo_media is provided via resolveTipoMedia(), which lets rows
with tipo_media like "audiolibro" be misclassified as music; change isMusic() so
that after calling resolveTipoMedia($formato, $tipoMedia) you treat any non-null
resolved tipo as authoritative: if it equals 'disco' return true, otherwise
return false and skip the normalizedCandidates()/MUSIC_FORMATS/preg_match
checks; refer to isMusic(), resolveTipoMedia(), normalizedCandidates(), and
MUSIC_FORMATS when making the change.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: acb748ff-3281-4eaf-ac9a-8c0ddd3acd74
📒 Files selected for processing (25)
app/Controllers/CsvImportController.phpapp/Controllers/FrontendController.phpapp/Controllers/LibraryThingImportController.phpapp/Controllers/LibriApiController.phpapp/Controllers/PluginController.phpapp/Controllers/ScrapeController.phpapp/Models/BookRepository.phpapp/Routes/web.phpapp/Support/BundledPlugins.phpapp/Support/MediaLabels.phpapp/Support/PluginManager.phpapp/Support/Updater.phpapp/Views/admin/plugins.phpapp/Views/frontend/book-detail.phpapp/Views/libri/scheda_libro.phpinstaller/classes/Installer.phpinstaller/database/migrations/migrate_0.5.4.sqlinstaller/database/schema.sqlstorage/plugins/discogs/DiscogsPlugin.phpstorage/plugins/discogs/views/settings.phptests/discogs-advanced.spec.jstests/discogs-import.spec.jstests/discogs-plugin.spec.jstests/multisource-scraping.spec.jstests/playwright.config.js
✅ Files skipped from review due to trivial changes (2)
- tests/playwright.config.js
- app/Support/BundledPlugins.php
🚧 Files skipped from review as they are similar to previous changes (6)
- app/Controllers/ScrapeController.php
- app/Support/PluginManager.php
- app/Controllers/LibriApiController.php
- app/Controllers/CsvImportController.php
- tests/discogs-plugin.spec.js
- app/Controllers/LibraryThingImportController.php
| let manualBookId = 0; | ||
| let importedBookId = 0; | ||
| let manualDiscId = 0; | ||
| let importedDisc1Id = 0; | ||
| let importedDisc2Id = 0; |
There was a problem hiding this comment.
Delete the rows this suite creates in afterAll.
Lines 114-118 already collect the created ids, but Lines 131-133 only close the context. On any reused E2E database this leaves permanent RUN_TAG rows behind and the suite will slowly pollute the catalog.
Also applies to: 131-133
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tests/multisource-scraping.spec.js` around lines 114 - 118, Test suite is
collecting created IDs (manualBookId, importedBookId, manualDiscId,
importedDisc1Id, importedDisc2Id) but never deletes the rows in afterAll; update
the afterAll block to delete the created RUN_TAG/catalog rows using those IDs
(call the existing cleanup/delete helpers or DB methods used elsewhere for
removing books/discs/tags) so the suite removes the records it created and
prevents RUN_TAG pollution (ensure the afterAll references the same variables:
manualBookId, importedBookId, manualDiscId, importedDisc1Id, importedDisc2Id).
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 3
♻️ Duplicate comments (6)
storage/plugins/discogs/DiscogsPlugin.php (2)
149-158:⚠️ Potential issue | 🟠 MajorBubble hook-registration failures up to activation.
After the rollback you only log the exception, so
onInstall()/onActivate()still return normally and the plugin can appear activated whileplugin_hooksis empty. This needs to abort the lifecycle call.🛠️ Suggested change
} catch (\Throwable $e) { if ($stmt instanceof \mysqli_stmt) { $stmt->close(); } try { $this->db->rollback(); } catch (\Throwable) { } \App\Support\SecureLogger::error($e->getMessage()); + throw $e; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@storage/plugins/discogs/DiscogsPlugin.php` around lines 149 - 158, The current catch block in DiscogsPlugin.php swallows exceptions (logs via App\Support\SecureLogger::error) then allows onInstall()/onActivate() to return normally, so hook-registration failures don't abort activation; modify the catch to abort the lifecycle by rethrowing the caught exception (or throw a new appropriate exception) after closing $stmt and attempting rollback, ensuring onInstall()/onActivate() cannot complete successfully when the rollback path is taken.
257-260:⚠️ Potential issue | 🟠 MajorThis Discogs title fallback still can’t run in the normal scrape path.
app/Controllers/ScrapeController.phpinvokesscrape.fetch.customwithnullas the initial value, andapp/Support/HookManager.phppasses that through as the first callback argument. So on a barcode miss,$currentResultis usuallynull,searchDiscogsByTitleArtist()returns immediately, and the code jumps straight to MusicBrainz instead of performing the promised Discogs title/artist fallback.Also applies to: 285-289
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@storage/plugins/discogs/DiscogsPlugin.php` around lines 257 - 260, The fallback path fails when $currentResult is null; update the Discogs fallback so it still runs on a barcode miss by making searchDiscogsByTitleArtist accept a null $currentResult (or detect null before calling) and extract title/artist from the incoming request/token instead, e.g. have searchDiscogsByTitleArtist($currentResult, $token) handle $currentResult===null by reading title/artist from $token/request context, then return the Discogs data to be passed into mergeBookData($currentResult, $discogsFallback); apply the same null-safe change to the other fallback call around the 285-289 block.app/Models/BookRepository.php (1)
1060-1075:⚠️ Potential issue | 🟠 MajorIgnore empty media signals in this fallback.
array_key_exists()is still too broad here. If the payload carriesformato,scraped_formato, orscraped_tipo_mediaas'', this block resolves back to'libro'and silently downgrades an existingdisco/dvdon an otherwise unrelated optional update.🛠️ Suggested change
- if ($this->hasColumn('tipo_media') && !array_key_exists('tipo_media', $cols)) { - $hasMediaSignal = array_key_exists('formato', $data) - || array_key_exists('scraped_formato', $data) - || array_key_exists('scraped_tipo_media', $data); + if ($this->hasColumn('tipo_media') && !array_key_exists('tipo_media', $cols)) { + $formato = trim((string) ($data['formato'] ?? ($data['scraped_formato'] ?? ''))); + $scrapedTipoMedia = trim((string) ($data['scraped_tipo_media'] ?? '')); + $hasMediaSignal = $formato !== '' || $scrapedTipoMedia !== ''; if ($hasMediaSignal) { $val = \App\Support\MediaLabels::resolveTipoMedia( - $data['formato'] ?? ($data['scraped_formato'] ?? null), - $data['scraped_tipo_media'] ?? null + $formato !== '' ? $formato : null, + $scrapedTipoMedia !== '' ? $scrapedTipoMedia : null ); $normalized = $this->normalizeEnumValue((string) $val, 'tipo_media', 'libro'); if ($normalized !== '') {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Models/BookRepository.php` around lines 1060 - 1075, The fallback currently treats empty-string signals as present because it uses array_key_exists for formato, scraped_formato and scraped_tipo_media; update the guard in the BookRepository block (inside the hasColumn('tipo_media') branch that calls \App\Support\MediaLabels::resolveTipoMedia and $this->normalizeEnumValue) to only consider non-empty signals (e.g. use isset or explicit !== '' checks) so that '' values are ignored; only call resolveTipoMedia and set $cols['tipo_media'] when at least one of those inputs is a non-empty, meaningful string to avoid silently downgrading existing tipo_media like 'disco'/'dvd'.app/Support/MediaLabels.php (1)
242-250:⚠️ Potential issue | 🟠 MajorCheck audiobook tokens before the generic music tokens.
Formats like
Audiobook CDoraudiolibro cassettesatisfy the'cd'/'cassette'branch first, soinferTipoMedia()returnsdiscoand never reaches the audiobook branch. Any code path that infers fromformatoalone will mislabel audiobook media as music.🛠️ Suggested change
foreach (self::normalizedCandidates($formato) as $candidate) { + if (str_contains($candidate, 'audiolibro') || str_contains($candidate, 'audiobook')) { + return 'audiolibro'; + } + foreach (['cdaudio', 'compactdisc', 'vinile', 'vinyl', 'lp', 'cd', 'cassetta', 'cassette', 'audiocassetta'] as $musicToken) { if (str_contains($candidate, $musicToken)) { return 'disco'; } } - - if (str_contains($candidate, 'audiolibro') || str_contains($candidate, 'audiobook')) { - return 'audiolibro'; - } if (str_contains($candidate, 'dvd') || str_contains($candidate, 'bluray') || str_contains($candidate, 'blu_ray')) { return 'dvd'; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Support/MediaLabels.php` around lines 242 - 250, The logic in inferTipoMedia (iterating candidates from normalizedCandidates($formato)) checks generic music tokens (e.g., 'cd', 'cassette') before audiobook tokens, causing inputs like "Audiobook CD" to be misclassified as 'disco'; fix this by checking audiobook tokens first for each $candidate (or moving the audiolibro/audiobook check above the music-token loop) so that if str_contains($candidate, 'audiolibro') || str_contains($candidate, 'audiobook') returns true it immediately returns 'audiolibro' before testing the music tokens; update any token arrays if needed to include other audiobook synonyms and keep normalizedCandidates($formato) usage unchanged.tests/discogs-advanced.spec.js (1)
125-150:⚠️ Potential issue | 🟡 MinorThe “Book” half of this JSON-LD check is still missing.
This test now validates
MusicAlbumon the music page, but the book side only checkstipo_mediain MySQL. A frontend regression that emits the wrong JSON-LD@typeforlibrowould still pass.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/discogs-advanced.spec.js` around lines 125 - 150, The test only asserts the MusicAlbum JSON-LD for the music item; add a symmetric frontend assertion that the book page emits a Schema.org JSON-LD with "@type": "Book". Specifically, in the same test (or a nearby test) fetch the book HTML (use page.request.get(`${BASE}/libro/${bookBookId}`)), extract jsonLdBlocks and parse them into schemas just like for musicHtml, then find a schema where schema['@type'] === 'Book' and assert it is truthy with a clear message (e.g., 'Frontend JSON-LD is missing Book for libro'); keep the existing dbQuery checks for tipo_media.installer/database/migrations/migrate_0.5.4.sql (1)
66-84:⚠️ Potential issue | 🟡 MinorBackfill the same
music|musikcases that runtime inference already treats asdisco.
App\Support\MediaLabels::inferTipoMedia()still maps whole-wordmusic/musiktodisco, but this migration leaves those upgraded rows aslibro. New imports and migrated data will disagree for the sameformatovalues.🛠️ Suggested change
UPDATE libri SET tipo_media = 'disco' WHERE tipo_media = 'libro' AND (LOWER(formato) LIKE '%cd%' OR LOWER(formato) LIKE '%compact disc%' OR LOWER(formato) LIKE '%vinyl%' OR LOWER(formato) LIKE '%vinile%' OR LOWER(formato) LIKE '%lp%' OR LOWER(formato) LIKE '%cassett%' OR LOWER(formato) LIKE '%audio cassetta%' OR LOWER(formato) LIKE '%audio-cassetta%' - OR LOWER(formato) LIKE '%audiocassetta%') + OR LOWER(formato) LIKE '%audiocassetta%' + OR LOWER(formato) REGEXP '(^|[^[:alnum:]])(music|musik)([^[:alnum:]]|$)') AND LOWER(formato) NOT LIKE '%audiolibro%' AND LOWER(formato) NOT LIKE '%audiobook%';🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@installer/database/migrations/migrate_0.5.4.sql` around lines 66 - 84, The migration should mirror App\Support\MediaLabels::inferTipoMedia() by also backfilling rows where formato indicates music/musik as disco; update the first UPDATE (the one setting tipo_media = 'disco' for tipo_media = 'libro') to include additional LOWER(formato) LIKE patterns that match whole-word "music" and "musik" (and common variants) so migrated rows match runtime inference in MediaLabels::inferTipoMedia().
🧹 Nitpick comments (2)
app/Controllers/FrontendController.php (1)
917-927: Share thelibricolumn cache.
app/Controllers/LibriApiController.php:550-570already has the same schema-probe responsibility forlibri. Extracting one helper would keep the frontend/API fallback logic from drifting the next time the migration-compatibility path changes.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Controllers/FrontendController.php` around lines 917 - 927, Extract the duplicated "does libri have this column" logic from FrontendController::hasLibriColumn and the similar probe in LibriApiController into a single shared helper (e.g., a new static method SchemaHelper::hasLibriColumn(mysqli $db, string $column): bool or a trait used by both controllers) and update both controllers to call that helper; preserve the current caching behavior by keeping a static $columnCache inside the shared method so both callers share the same cache and continue to use $db->real_escape_string and "SHOW COLUMNS FROM libri LIKE ..." for the probe.tests/discogs-import.spec.js (1)
88-96: Wait on the field state instead of sleeping 8 seconds.This scrape path is rate-limited and can take different branches, so the fixed timeout is both slower than needed and still prone to CI flakes. Waiting until
titolobecomes non-empty is much more stable.♻️ Suggested change
- // Wait for scraping response (Discogs needs time + rate limits) - // The scraping service tries multiple sources — wait up to 20s - await page.waitForTimeout(8000); - - // Check if title was populated const titleField = page.locator('input[name="titolo"]'); + await expect(titleField).toHaveValue(/\S+/, { timeout: 20000 }); const titleValue = await titleField.inputValue();🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/discogs-import.spec.js` around lines 88 - 96, Replace the fixed sleep with waiting for the titolo input to become non-empty: remove page.waitForTimeout(8000) and instead wait until document.querySelector('input[name="titolo"]') has a trimmed value length > 0 (e.g. use page.waitForFunction(() => document.querySelector('input[name="titolo"]')?.value?.trim().length > 0) or Playwright's expect on the locator). Keep the existing titleField / titleValue checks but only read titleValue after the wait to avoid flakiness.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/Controllers/PluginController.php`:
- Around line 433-449: In PluginController::index(), before passing
$pluginSettings to the template, redact the decrypted Discogs token the same way
existing keys are redacted: when iterating plugin settings (the code that
currently blanks out 'google_books_api_key' and 'api_key'), also blank out
'api_token' (from PluginManager::getSettings()) so the decrypted token is not
exposed to the view; update the logic that prepares $pluginSettings for the
admin view to set $pluginSettings['api_token'] = '' (or remove the key)
following the existing pattern used for api_key/google_books_api_key.
- Around line 229-235: Add a defensive is_callable() check for
getSettingsViewPath() in PluginController before invoking it: when you retrieve
$pluginInstance via getPluginInstance($pluginId) ensure you verify both
is_callable([$pluginInstance, 'hasSettingsPage']) and
is_callable([$pluginInstance, 'getSettingsViewPath']) (in addition to
$pluginInstance->hasSettingsPage()) before calling getSettingsViewPath(); if
either callable check fails, return the 404 response as the existing branch
does. This prevents a fatal undefined-method call if a plugin advertises
hasSettingsPage but doesn't implement getSettingsViewPath.
In `@app/Support/Updater.php`:
- Around line 2264-2272: In the catch (\Throwable $e) block that currently calls
rename($backupPath, $targetPluginPath), check the return value of rename and
handle failures instead of ignoring them: after attempting rename($backupPath,
$targetPluginPath) verify it returned true, and if it returned false (or threw),
log a clear recovery-failure message or throw a new exception (e.g.
RuntimeException) that includes both the original $e->getMessage() and the
backup/target paths so the recovery failure is visible; keep the existing
removeDirectoryTree($stagingPath) call and ensure any new exception includes
context about the failed rename for troubleshooting.
---
Duplicate comments:
In `@app/Models/BookRepository.php`:
- Around line 1060-1075: The fallback currently treats empty-string signals as
present because it uses array_key_exists for formato, scraped_formato and
scraped_tipo_media; update the guard in the BookRepository block (inside the
hasColumn('tipo_media') branch that calls
\App\Support\MediaLabels::resolveTipoMedia and $this->normalizeEnumValue) to
only consider non-empty signals (e.g. use isset or explicit !== '' checks) so
that '' values are ignored; only call resolveTipoMedia and set
$cols['tipo_media'] when at least one of those inputs is a non-empty, meaningful
string to avoid silently downgrading existing tipo_media like 'disco'/'dvd'.
In `@app/Support/MediaLabels.php`:
- Around line 242-250: The logic in inferTipoMedia (iterating candidates from
normalizedCandidates($formato)) checks generic music tokens (e.g., 'cd',
'cassette') before audiobook tokens, causing inputs like "Audiobook CD" to be
misclassified as 'disco'; fix this by checking audiobook tokens first for each
$candidate (or moving the audiolibro/audiobook check above the music-token loop)
so that if str_contains($candidate, 'audiolibro') || str_contains($candidate,
'audiobook') returns true it immediately returns 'audiolibro' before testing the
music tokens; update any token arrays if needed to include other audiobook
synonyms and keep normalizedCandidates($formato) usage unchanged.
In `@installer/database/migrations/migrate_0.5.4.sql`:
- Around line 66-84: The migration should mirror
App\Support\MediaLabels::inferTipoMedia() by also backfilling rows where formato
indicates music/musik as disco; update the first UPDATE (the one setting
tipo_media = 'disco' for tipo_media = 'libro') to include additional
LOWER(formato) LIKE patterns that match whole-word "music" and "musik" (and
common variants) so migrated rows match runtime inference in
MediaLabels::inferTipoMedia().
In `@storage/plugins/discogs/DiscogsPlugin.php`:
- Around line 149-158: The current catch block in DiscogsPlugin.php swallows
exceptions (logs via App\Support\SecureLogger::error) then allows
onInstall()/onActivate() to return normally, so hook-registration failures don't
abort activation; modify the catch to abort the lifecycle by rethrowing the
caught exception (or throw a new appropriate exception) after closing $stmt and
attempting rollback, ensuring onInstall()/onActivate() cannot complete
successfully when the rollback path is taken.
- Around line 257-260: The fallback path fails when $currentResult is null;
update the Discogs fallback so it still runs on a barcode miss by making
searchDiscogsByTitleArtist accept a null $currentResult (or detect null before
calling) and extract title/artist from the incoming request/token instead, e.g.
have searchDiscogsByTitleArtist($currentResult, $token) handle
$currentResult===null by reading title/artist from $token/request context, then
return the Discogs data to be passed into mergeBookData($currentResult,
$discogsFallback); apply the same null-safe change to the other fallback call
around the 285-289 block.
In `@tests/discogs-advanced.spec.js`:
- Around line 125-150: The test only asserts the MusicAlbum JSON-LD for the
music item; add a symmetric frontend assertion that the book page emits a
Schema.org JSON-LD with "@type": "Book". Specifically, in the same test (or a
nearby test) fetch the book HTML (use
page.request.get(`${BASE}/libro/${bookBookId}`)), extract jsonLdBlocks and parse
them into schemas just like for musicHtml, then find a schema where
schema['@type'] === 'Book' and assert it is truthy with a clear message (e.g.,
'Frontend JSON-LD is missing Book for libro'); keep the existing dbQuery checks
for tipo_media.
---
Nitpick comments:
In `@app/Controllers/FrontendController.php`:
- Around line 917-927: Extract the duplicated "does libri have this column"
logic from FrontendController::hasLibriColumn and the similar probe in
LibriApiController into a single shared helper (e.g., a new static method
SchemaHelper::hasLibriColumn(mysqli $db, string $column): bool or a trait used
by both controllers) and update both controllers to call that helper; preserve
the current caching behavior by keeping a static $columnCache inside the shared
method so both callers share the same cache and continue to use
$db->real_escape_string and "SHOW COLUMNS FROM libri LIKE ..." for the probe.
In `@tests/discogs-import.spec.js`:
- Around line 88-96: Replace the fixed sleep with waiting for the titolo input
to become non-empty: remove page.waitForTimeout(8000) and instead wait until
document.querySelector('input[name="titolo"]') has a trimmed value length > 0
(e.g. use page.waitForFunction(() =>
document.querySelector('input[name="titolo"]')?.value?.trim().length > 0) or
Playwright's expect on the locator). Keep the existing titleField / titleValue
checks but only read titleValue after the wait to avoid flakiness.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 15c8c2a9-13ea-47e4-abfe-cf057bb21191
📒 Files selected for processing (13)
app/Controllers/FrontendController.phpapp/Controllers/PluginController.phpapp/Models/BookRepository.phpapp/Support/MediaLabels.phpapp/Support/RouteTranslator.phpapp/Support/Updater.phpapp/Views/admin/plugins.phpinstaller/classes/Installer.phpinstaller/database/migrations/migrate_0.5.4.sqlinstaller/database/schema.sqlstorage/plugins/discogs/DiscogsPlugin.phptests/discogs-advanced.spec.jstests/discogs-import.spec.js
✅ Files skipped from review due to trivial changes (1)
- app/Support/RouteTranslator.php
🚧 Files skipped from review as they are similar to previous changes (1)
- app/Views/admin/plugins.php
1. LibriController: add tipo_media to store/update field defaults so form POST value flows through createBasic/updateBasic directly 2. CsvImportController: hasColumn guard on tipo_media in raw SQL prevents 'Unknown column' on pre-0.5.4 databases 3. LibraryThingImportController: same hasColumn guard on all 4 SQL blocks 4. DiscogsPlugin: Deezer enrichment gated to music sources only prevents attaching music album covers to books
- inferTipoMedia: check audiobook BEFORE music tokens (prevents "Audiobook CD" → disco misclassification) - updateOptionals: non-empty string check instead of array_key_exists (prevents '' from downgrading disco→libro) - DiscogsPlugin: rethrow on hook registration failure - DiscogsPlugin: null guard on title fallback (no title = skip) - PluginController: is_callable guard for getSettingsViewPath - Updater: log rename failure in plugin recovery path - Migration: REGEXP for whole-word music/musik → disco
|
@CodeRabbit full review |
✅ Actions performedFull review triggered. |
|
@CodeRabbit review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 10
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
app/Controllers/ScrapeController.php (1)
143-149:⚠️ Potential issue | 🟠 MajorResolve
tipo_mediabefore any book-only enrichment.
enrichWithSbnData()still runs beforeensureTipoMedia(), so a Discogs EAN can still go through the SBN enrichment path and merge book fields before the non-book guard innormalizeIsbnFields()executes. MoveensureTipoMedia()above this block and skip SBN enrichment unlesstipo_media === 'libro'.Suggested fix
- // Try to enrich with SBN data if Dewey is missing - if (empty($payload['classificazione_dewey'])) { - $payload = $this->enrichWithSbnData($payload, $cleanIsbn); - } - $payload = $this->ensureTipoMedia($payload); + if (($payload['tipo_media'] ?? 'libro') === 'libro' && empty($payload['classificazione_dewey'])) { + $payload = $this->enrichWithSbnData($payload, $cleanIsbn); + }Apply the same reordering in the fallback branch.
Also applies to: 213-219
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Controllers/ScrapeController.php` around lines 143 - 149, Move the call to ensureTipoMedia() so it runs before any SBN enrichment and guard SBN enrichment on tipo_media === 'libro'; specifically, call $this->ensureTipoMedia($payload) before the block that invokes $this->enrichWithSbnData($payload, $cleanIsbn) and change the logic to only call enrichWithSbnData when $payload['tipo_media'] === 'libro' (do the same reordering and guard in the fallback branch that currently mirrors lines 213-219) so Discogs EANs won't be merged with book-only SBN fields.app/Support/Updater.php (1)
2211-2237:⚠️ Potential issue | 🟠 MajorFail the update when a declared bundled plugin is missing from the release package.
This currently degrades to a debug log and success, so users can complete an update without ever receiving a plugin that
BundledPlugins::LISTsays should be bundled. That leaves the upgraded core advertising or expecting plugin code that is not on disk.💡 Proposed fix
if (!is_dir($sourcePluginsDir)) { - $this->debugLog('DEBUG', 'Nessuna directory plugins nel pacchetto'); - return; + if (count(BundledPlugins::LIST) > 0) { + throw new Exception(__('Directory plugins bundled mancante nel pacchetto di aggiornamento.')); + } + $this->debugLog('DEBUG', 'Nessuna directory plugins nel pacchetto'); + return; } @@ $sourcePluginPath = $sourcePluginsDir . '/' . $pluginSlug; if (!is_dir($sourcePluginPath)) { - $this->debugLog('DEBUG', 'Plugin bundled non presente nel pacchetto', ['plugin' => $pluginSlug]); - continue; + throw new Exception(sprintf(__('Plugin bundled mancante nel pacchetto di aggiornamento: %s'), $pluginSlug)); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Support/Updater.php` around lines 2211 - 2237, The code currently logs missing bundled plugins and continues; instead, when iterating BundledPlugins::LIST and a corresponding $sourcePluginPath does not exist (after computing $pluginSlug via normalizeBundledPluginSlug), stop the update and throw an exception (or return a failure) so the update fails visibly; replace the debugLog('DEBUG', 'Plugin bundled non presente nel pacchetto', ['plugin' => $pluginSlug]) + continue with a thrown Exception (include the plugin slug and source path in the message) so callers cannot proceed with a core upgrade that declares a bundled plugin absent from $sourcePluginsDir.app/Models/BookRepository.php (1)
1003-1075:⚠️ Potential issue | 🟠 MajorPersist
formatoin the optional scrape-update path.This block now derives
tipo_mediafromformato/scraped_formato, butupdateOptionals()still never writes the format column itself. A Discogs enrichment can therefore savetipo_media='disco'while leavingformatoempty or stale, so the new music-format metadata is lost.💡 Suggested fix
- foreach (['numero_pagine', 'ean', 'data_pubblicazione', 'anno_pubblicazione', 'traduttore', 'illustratore', 'curatore', 'collana', 'edizione', 'tipo_media'] as $c) { + foreach (['numero_pagine', 'ean', 'data_pubblicazione', 'anno_pubblicazione', 'traduttore', 'illustratore', 'curatore', 'collana', 'edizione', 'formato', 'tipo_media'] as $c) { + if ($this->hasColumn('formato') && !isset($cols['formato']) && !empty($data['scraped_formato'])) { + $cols['formato'] = (string) $data['scraped_formato']; + } - $formato = trim((string) ($data['formato'] ?? ($data['scraped_formato'] ?? ''))); + $formato = trim((string) ($cols['formato'] ?? $data['formato'] ?? ($data['scraped_formato'] ?? '')));🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Models/BookRepository.php` around lines 1003 - 1075, The updateOptionals path derives tipo_media from formato/scraped_formato but never persists the formato column, causing format metadata to be lost; inside the same method (e.g., BookRepository::updateOptionals) where you compute $formato and $scrapedTipoMedia and set $cols['tipo_media'], also check hasColumn('formato') and if formato/scraped_formato is non-empty and 'formato' is not already set in $cols, set $cols['formato'] to the trimmed value (prefer $data['formato'] if present, otherwise scraped_formato) so the raw format is stored alongside the normalized tipo_media. Ensure you don't overwrite an existing $cols['formato'].app/Controllers/LibraryThingImportController.php (1)
913-938:⚠️ Potential issue | 🟠 MajorMap music
Mediaintoformato, not justtipo_media.
parseLibraryThingRow()now infers a musictipo_media, but theMedia→formatomap still only covers book-like labels. For CD/LP/cassette rows,formatostays empty and the save paths below fall back to'cartaceo', so imported music lands with a book format.Also applies to: 1348-1351, 1423-1426, 1536-1540, 1615-1619
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Controllers/LibraryThingImportController.php` around lines 913 - 938, The Media→formato mapping only covers book-like labels, so music rows (CD/LP/cassette) end up defaulting to 'cartaceo'; update the $mediaMap used in parseLibraryThingRow (the block that sets $media, iterates $mediaMap and assigns $result['formato']) to include common music labels (e.g., 'cd', 'compact disc', 'cd audio', 'lp', 'vinile', 'vinyl', 'record', 'album', 'cassette', 'tape', etc.) mapped to the appropriate music formato value you use (e.g., 'musica', 'cd', 'vinile' as per your conventions), and mirror the same change in the other duplicated Media→formato mappings mentioned (the blocks around the other occurrences) so formato is correctly set for music before falling back to 'cartaceo'.
♻️ Duplicate comments (12)
storage/plugins/discogs/README.md (1)
13-19:⚠️ Potential issue | 🟡 MinorClarify Discogs title fallback before MusicBrainz fallback.
The strategy lists barcode → details → MusicBrainz fallback, but PR objectives state a Discogs title/title+artist fallback after barcode. Please document that intermediate step (or explicitly state it was intentionally removed) to keep behavior/docs/objectives aligned.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@storage/plugins/discogs/README.md` around lines 13 - 19, The documentation currently omits the intermediate Discogs title/title+artist fallback between barcode search (GET /database/search?barcode={ean}&type=release) and the MusicBrainz fallback; update the "Strategia di ricerca" section to either re-add an explicit step describing the Discogs title or title+artist fallback (e.g., "2a. Ricerca per titolo o titolo+artista su Discogs -- GET /database/search?query={title}+{artist}") or add a short note stating this step was intentionally removed, and ensure the sequence numbering adjusts (barcode → details → [title/title+artist fallback] → MusicBrainz → Deezer) so docs match PR objectives and implementation.app/Views/admin/plugins.php (1)
244-244:⚠️ Potential issue | 🟠 MajorUse a named settings route instead of concatenating path segments.
Line 244 and Line 300 still construct the settings URL by appending
/{id}/settingsmanually. Please switch both to a dedicated named route (e.g.route_path('...settings...', ['id' => ...])orRouteTranslator::route(...)) and keephtmlspecialchars(..., ENT_QUOTES, 'UTF-8')around the final URL.As per coding guidelines: Never use hardcoded routes. Use
route_path('key')orRouteTranslator::route('key')instead of hardcoded paths like/accedior/login.Also applies to: 300-301
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Views/admin/plugins.php` at line 244, The settings URL is being built by concatenating route_path('plugins') with "/{id}/settings"; replace both occurrences (the data-settings-url at the lines referenced) to call the named settings route instead (e.g. route_path('plugins.settings', ['id' => (int)$plugin['id']]) or RouteTranslator::route('plugins.settings', ['id' => (int)$plugin['id']])) and wrap that call with htmlspecialchars(..., ENT_QUOTES, 'UTF-8') just like before so no hardcoded path segments remain.app/Views/libri/partials/book_form.php (1)
3615-3624:⚠️ Potential issue | 🟡 MinorWhitelist scraped
tipo_mediabefore applying it to the select.Line 3620 applies raw
data.tipo_media. If a source returns non-canonical labels, the selected value can become inconsistent. Normalize and allow only keys fromMediaLabels::allTypes()before assignment.🔧 Suggested guard
// Auto-set tipo_media from scraped data try { if (data.tipo_media) { const tipoMediaSelect = document.getElementById('tipo_media'); - if (tipoMediaSelect) { - tipoMediaSelect.value = data.tipo_media; + const allowedTipoMedia = new Set(<?= json_encode(array_keys(\App\Support\MediaLabels::allTypes()), JSON_HEX_TAG) ?>); + const normalizedTipoMedia = String(data.tipo_media).trim().toLowerCase(); + if (tipoMediaSelect && allowedTipoMedia.has(normalizedTipoMedia)) { + tipoMediaSelect.value = normalizedTipoMedia; } } } catch (err) { }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Views/libri/partials/book_form.php` around lines 3615 - 3624, The code currently assigns raw data.tipo_media into the select element; instead, fetch the canonical allowed keys from MediaLabels::allTypes() (or a client-side equivalent whitelist), normalize/transform incoming data.tipo_media to a canonical key (e.g., lowercase/trim or map known aliases) and only set document.getElementById('tipo_media').value if the normalized value exists in the whitelist; otherwise skip assignment or set a safe default. Update the tipo_media assignment block (the try that references tipoMediaSelect and data.tipo_media) to perform the whitelist check and normalization before modifying the select.app/Views/frontend/catalog.php (1)
1308-1324:⚠️ Potential issue | 🟡 MinorFinish wiring
tipo_mediathrough the AJAX filter state.The server-rendered list is fine, but after
updateFilter('tipo_media', ...)orremoveFilter('tipo_media')the sidebar never reapplies itsactiveclass, and the active-filter chip still falls back to raw enum values likedisco/dvd. Give this filter a stable JS hook, emit a localized value→label map from the same$tipoFilterssource, and sync it the same way availability is synced.Based on learnings, Applies to **/*.php : Use
json_encode(..., JSON_HEX_TAG)for any PHP→JS output in views.Also applies to: 1451-1487, 1539-1667
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Views/frontend/catalog.php` around lines 1308 - 1324, The sidebar isn’t reapplying the active class or localized labels because tipo_media isn’t wired into the client-side filter state; fix by exporting the same $tipoFilters PHP array to JS (serialize with json_encode using JSON_HEX_TAG and JSON_HEX_APOS for safety) into a stable JS variable/map and attach a stable DOM hook (e.g., data-filter="tipo_media" on the filter container or each <a>) so updateFilter('tipo_media', ...) and removeFilter('tipo_media') can look up the localized label and re-render the active class exactly like the availability sync does; update the client-side code that applies active classes and chips to consult this new tipo_media map and the DOM hook when toggling filters.app/Views/libri/index.php (1)
688-697:⚠️ Potential issue | 🟡 MinorRender the localized media label here too.
The filter dropdown already uses
MediaLabels::allTypes(), but the table tooltip and active-filter chip still show raw enum keys likediscoandaudiolibro. Reuse the same label map in JS so these surfaces stay translated and do not drift from the backend metadata.♻️ Proposed fix
+ const mediaLabels = <?= json_encode( + array_map(static fn($meta) => __($meta['label']), \App\Support\MediaLabels::allTypes()), + JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT + ) ?>; ... { // Media type icon data: 'tipo_media', orderable: false, searchable: false, width: '30px', className: 'text-center align-middle', render: function(data) { const icons = { libro: 'fa-book', disco: 'fa-compact-disc', audiolibro: 'fa-headphones', dvd: 'fa-film', altro: 'fa-box' }; - return '<i class="fas ' + (icons[data] || 'fa-book') + ' text-gray-400" title="' + (data || 'libro') + '"></i>'; + const label = mediaLabels[data] || data || <?= json_encode(__('Libro'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>; + return '<i class="fas ' + (icons[data] || 'fa-book') + ' text-gray-400" title="' + escapeHtml(label) + '"></i>'; } }, ... - if (tipoMedia) filters.push({ key: 'tipo_media_filter', label: `${<?= json_encode(__("Tipo Media"), JSON_HEX_TAG) ?>}: ${tipoMedia}`, icon: 'fa-compact-disc' }); + if (tipoMedia) filters.push({ key: 'tipo_media_filter', label: `${<?= json_encode(__("Tipo Media"), JSON_HEX_TAG) ?>}: ${mediaLabels[tipoMedia] || tipoMedia}`, icon: 'fa-compact-disc' });As per coding guidelines
Use json_encode(..., JSON_HEX_TAG) for any PHP→JS output in views.Also applies to: 885-886
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Views/libri/index.php` around lines 688 - 697, The tooltip/title in the DataTable render (column data 'tipo_media' inside the render: function) currently shows raw enum keys; fix this by emitting the PHP label map from MediaLabels::allTypes() into JS (use json_encode(MediaLabels::allTypes(), JSON_HEX_TAG)) to create a labels map variable, then change the render to use labels[data] || labels['libro'] for the title and any places that display the human label (also update the active-filter chip rendering referenced around the other usages noted). Ensure you replace raw enum usage with the labels map in the render function and the other occurrences mentioned (the similar spots at the other referenced lines).app/Controllers/LibriApiController.php (1)
35-35:⚠️ Potential issue | 🟡 MinorNormalize
tipo_mediabefore casting it to string.An array query param like
?tipo_media[]=discostill reaches this line raw. In PHP that can degrade into'Array'and emit an array-to-string warning, so the filter silently stops matching and can corrupt the JSON response under stricter error reporting. The frontend controller already hardens this path.♻️ Proposed fix
- $tipo_media = trim((string) ($q['tipo_media'] ?? '')); + $rawTipoMedia = $q['tipo_media'] ?? ''; + if (is_array($rawTipoMedia)) { + $rawTipoMedia = $rawTipoMedia[0] ?? ''; + } + $tipo_media = is_scalar($rawTipoMedia) ? trim((string) $rawTipoMedia) : '';#!/bin/bash # Compare `tipo_media` normalization across the two controllers. # Expectation: # - FrontendController shows an `is_array` guard. # - LibriApiController still casts the raw query param directly. rg -n -C3 "tipo_media" app/Controllers/FrontendController.php app/Controllers/LibriApiController.php🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Controllers/LibriApiController.php` at line 35, The line setting $tipo_media in LibriApiController should guard against array query params before casting: detect if ($q['tipo_media']) is an array and pull a sensible scalar (e.g. the first element or implode) and then trim/cast, otherwise cast as now; update the assignment that uses $q['tipo_media'] so it mirrors the FrontendController's is_array guard to avoid array-to-string warnings and broken filtering.app/Support/Updater.php (1)
2244-2280:⚠️ Potential issue | 🟠 MajorBundled plugin swaps are still outside the updater rollback boundary.
Once one of these renames succeeds, any later failure here or later in
installUpdate()only restores the directories backed up bybackupAppFiles(). The already-swappedstorage/plugins/<slug>tree stays on the new version, so a failed update can still leave old core code running against new plugin code. Please either include bundled plugins inbackupAppFiles()/restoreAppFiles()or defer the final swap until the whole install commits.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Support/Updater.php` around lines 2244 - 2280, The bundled-plugin directory swap is happening outside the main rollback boundary so a partial failure can leave the plugin directory at the new version; modify the updater to include bundled plugins in the main backup/restore flow or postpone the final rename until the install commit: either (A) extend backupAppFiles()/restoreAppFiles() to include storage/plugins/<slug> (ensure backupAppFiles() records the same backupPath and restoreAppFiles() can restore it) so copyDirectoryRecursive/rename of targetPluginPath happens inside the rollback scope, or (B) change the flow in installUpdate()/Updater::copyDirectoryRecursive block to copy plugin files into a staging area and defer the rename of $stagingPath -> $targetPluginPath (and removal of $backupPath) until after all other install steps succeed (so the final swap is performed only once the install commits). Ensure references to copyDirectoryRecursive, $stagingPath, $targetPluginPath, $backupPath, backupAppFiles(), restoreAppFiles(), and installUpdate() are updated accordingly.app/Controllers/PluginController.php (1)
437-453:⚠️ Potential issue | 🟠 MajorRedact the Discogs token before it reaches the plugins view.
Once this branch starts saving
api_token,index()will decrypt it viaPluginManager::getSettings()and pass it into$pluginSettings, because only the Google Books / API Book Scraper secrets are masked there today. Add the same masking forapi_tokenso plaintext credentials never enter the template scope.💡 Suggested fix in
index()+ if ($plugin['name'] === 'discogs' && array_key_exists('api_token', $settings)) { + $settings['api_token_exists'] = $settings['api_token'] !== ''; + $settings['api_token'] = $settings['api_token_exists'] ? '••••••••' : ''; + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Controllers/PluginController.php` around lines 437 - 453, The Discogs plaintext api_token is being saved and then exposed in the view because PluginManager::getSettings() decrypts it and index() passes $pluginSettings into the template; update PluginController::index() to mask/remove the 'api_token' value before rendering (e.g., replace the 'api_token' string with a boolean like 'has_token' based on whether it's non-empty or unset the key) so the template never receives the decrypted token. Locate the handling of $pluginSettings (after $this->pluginManager->getSettings(...)) and add logic to detect the 'discogs' plugin or the 'api_token' key and transform it to a masked indicator.tests/multisource-scraping.spec.js (1)
114-118:⚠️ Potential issue | 🟠 MajorClean up the five catalog rows this suite creates in
afterAll().The suite already tracks all created IDs, but teardown only closes the browser. On any reused E2E database this leaves the RUN_TAG books/discs behind and can contaminate later catalog/filter assertions.
Also applies to: 131-133
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/multisource-scraping.spec.js` around lines 114 - 118, The test suite declares ID trackers (manualBookId, importedBookId, manualDiscId, importedDisc1Id, importedDisc2Id) but afterAll() only closes the browser; update afterAll() to remove the five created catalog rows using those IDs — call the existing cleanup APIs or helpers (e.g., deleteBookById/deleteDiscById or the test harness catalog cleanup function) for each non-zero ID in that list, handling errors gracefully so tests teardown the RUN_TAG books/discs and avoid contaminating subsequent E2E runs; ensure you reference and clear manualBookId, importedBookId, manualDiscId, importedDisc1Id, and importedDisc2Id inside afterAll().storage/plugins/discogs/plugin.json (1)
10-12:⚠️ Potential issue | 🟠 MajorDon't advertise app-version gating unless the runtime enforces it.
If
PluginManagerstill only checksrequires_php, these keys won't block incompatible installs or activations and the manifest is promising a guard that doesn't exist yet.Confirm the runtime check in
PluginManager:#!/bin/bash rg -n "requires_app|max_app_version|requires_php|version_compare" app/Support/PluginManager.php -C 3🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@storage/plugins/discogs/plugin.json` around lines 10 - 12, The plugin manifest advertises requires_app and max_app_version but PluginManager currently only enforces requires_php; update the runtime to enforce these fields or remove them from the manifest. Add checks in the PluginManager class (e.g., the method that validates manifests such as validatePluginManifest/validateRequirements or registerPlugin) to read manifest['requires_app'] and manifest['max_app_version'] and use PHP's version_compare to ensure the current app version satisfies requires_app (>=) and is <= max_app_version before allowing install/activation; return/log a clear error when the checks fail. Alternatively, if you don't want runtime enforcement now, delete the requires_app and max_app_version keys from plugin.json so the manifest doesn't promise unsupported gating.storage/plugins/discogs/DiscogsPlugin.php (1)
369-374:⚠️ Potential issue | 🟠 MajorKeep
tipo_mediaauthoritative when deciding Deezer enrichment.This OR-chain can flip an explicit
audiolibroback to music viaformatorsource, so CD/cassette audiobooks can pick up album covers and genres. Use one resolved media type for this gate.💡 Minimal fix
- $resolvedType = \App\Support\MediaLabels::inferTipoMedia($data['format'] ?? $data['formato'] ?? ''); - $isMusicSource = ($data['tipo_media'] ?? '') === 'disco' - || $resolvedType === 'disco' - || ($data['source'] ?? '') === 'discogs' - || ($data['source'] ?? '') === 'musicbrainz'; + $resolvedType = \App\Support\MediaLabels::resolveTipoMedia( + $data['format'] ?? $data['formato'] ?? null, + $data['tipo_media'] ?? null + ); + $isMusicSource = $resolvedType === 'disco';🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@storage/plugins/discogs/DiscogsPlugin.php` around lines 369 - 374, Compute a single authoritative media type and prefer an explicit tipo_media over the inferred format: call inferTipoMedia as you do, then set a single $resolvedMedia = $data['tipo_media'] ?? $resolvedType; finally use $resolvedMedia in the Deezer gate (e.g. $isMusicSource = $resolvedMedia === 'disco' || ($data['source'] ?? '') === 'discogs' || ($data['source'] ?? '') === 'musicbrainz') so an explicit audiolibro in tipo_media is not overridden by format or source.tests/discogs-plugin.spec.js (1)
64-80:⚠️ Potential issue | 🟠 MajorHandle installation before asserting activation on a clean database.
Test 1 explicitly accepts
plugins.name = 'discogs'being absent, but Test 2 then immediately requires a DB row and only looks forAttiva. On a fresh environment, the suite fails without ever exercising the install flow.Also applies to: 83-97
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/discogs-plugin.spec.js` around lines 64 - 80, The test assumes the discogs plugin is installed but may run on a clean DB; update the test to handle installation before asserting activation: after computing pluginExists (dbQuery) and locating discogsCard (page.locator/... getByRole heading), if parseInt(pluginExists) === 0 then interact with discogsCard to trigger the install flow (e.g., click the install button within that card or navigate the install UI), wait for the install to complete (waitForLoadState / waitForSelector for the "Attiva" or installation confirmation) and re-query the DB (dbQuery) or pageContent to confirm the plugin row exists before asserting the final expect; ensure the same pattern is applied for the second block (lines 83-97) as well.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/Controllers/LibriController.php`:
- Line 3041: The CSV export currently synthesizes a 'libro' type when
$libro['tipo_media'] is null; locate the CSV row construction in LibriController
where $libro['tipo_media'] is used and stop defaulting to 'libro' — instead emit
the raw null/empty value (e.g. leave it null or an empty string) so the exporter
does not invent a media type; replace the expression $libro['tipo_media'] ??
'libro' with a non-defaulting alternative (e.g. $libro['tipo_media'] ?? null or
$libro['tipo_media'] ?? '') so imports can preserve or infer the proper media
type.
In `@app/Support/PluginManager.php`:
- Around line 356-364: getPluginInstance() currently calls instantiatePlugin()
which can throw on missing/broken plugin files; wrap the
instantiatePlugin($plugin) call in a try/catch that catches Throwable/Exception,
handle the error (log or suppress) and return null so the public method honors
its nullable contract instead of letting exceptions bubble up and cause a 500;
reference the methods getPluginInstance and instantiatePlugin when applying this
change.
In `@app/Views/frontend/book-detail.php`:
- Around line 2239-2240: The view currently uses
htmlspecialchars($related['autori'] ?? __($relatedIsMusic ? 'Artista
sconosciuto' : 'Autore sconosciuto'), ...) which only falls back when autori is
null but leaves empty strings; change this to explicitly treat empty/whitespace
as missing by checking trim($related['autori'] ?? '') (or empty after trim) and
using the music-aware fallback (__($relatedIsMusic ? 'Artista sconosciuto' :
'Autore sconosciuto')) when that check fails; update the expression around
htmlspecialchars in the related-book-author card so empty autori strings render
the fallback.
In `@app/Views/frontend/catalog-grid.php`:
- Around line 41-44: The media badge span (class "book-media-badge") is
currently icon-only and uses title, which isn't sufficient for accessibility;
update the span that uses MediaLabels::tipoMediaDisplayName($book['tipo_media'])
to include an accessible name (e.g., add aria-label with that display name or
add visually hidden text inside the span) and mark the <i> icon produced by
MediaLabels::icon($book['tipo_media']) as decorative (e.g., aria-hidden="true"
on the <i>) so screen readers announce the media label from the span rather than
the icon.
In `@app/Views/libri/index.php`:
- Around line 314-315: The icon-only column header using the <th> that contains
<i class="fas fa-compact-disc text-gray-400" title="<?= __("Tipo Media")
?>"></i> is inaccessible; update that <th> to provide an accessible name by
either adding an aria-label (e.g., aria-label="<?= __('Tipo Media') ?>") on the
<th> or inserting visually hidden text (a <span class="sr-only"><?= __('Tipo
Media') ?></span>) next to the icon, and mark the icon decorative with
aria-hidden="true" so screen readers read the header text only. Ensure the
second header (<?= __("Cover") ?>) remains unchanged.
In `@storage/plugins/discogs/DiscogsPlugin.php`:
- Around line 269-273: The MusicBrainz fallback call currently passes null for
the $fallbackBarcode, losing the validated barcode; update the call to
searchMusicBrainz(...) in this branch to pass the validated barcode (e.g.,
$isbn) as the third argument so the function receives the validated EAN (replace
the null with $isbn), then merge the result as before via
mergeBookData($currentResult, $mbResult).
In `@storage/plugins/discogs/views/settings.php`:
- Around line 23-31: Validate that $_POST['api_token'] is a string before
calling trim(): check is_string($_POST['api_token']) (or cast only when safe)
and if not, set $apiToken = '' and populate $errorMessage with a validation
error instead of calling trim on an array or other type; then proceed to build
$settings and call $plugin->saveSettings($settings) only when $apiToken is a
valid string. Ensure this check is done before the existing lines that define
$apiToken, $settings, and call $plugin->saveSettings so the page returns a
user-friendly validation error rather than throwing a TypeError.
In `@tests/discogs-import.spec.js`:
- Around line 115-120: The test currently asserts the real public barcode
(TEST_BARCODE) is present in the scraped inputs 'input[name="ean"]' or
'input[name="isbn13"]', which can persist a shared identifier; before submitting
the imported row you should overwrite the scraped identifier with a run-scoped
value: locate the fields using page.locator('input[name="ean"]') and
page.locator('input[name="isbn13"]') and set (fill) them to a generated unique
barcode/string (same pattern used by the multisource suite) so the form saves a
test-specific identifier; apply this replacement wherever the test reads/writes
ean/isbn13 in the block around the expect (also for the related checks in lines
123-148).
- Around line 51-57: The cleanup currently does a raw DELETE via dbExec on the
libri table inside test.afterAll (using createdId) which can violate FK
constraints and the empty catch hides failures; instead call the app's normal
delete/soft-delete path (e.g., use the same controller/service used by the admin
form) or explicitly delete dependent copie rows first (DELETE FROM copie WHERE
libro_id = ...) then soft-delete libri, and replace the empty catch with an
assertion or a logged/raised error so test cleanup failures are visible; locate
the cleanup in test.afterAll, the createdId variable, and the dbExec call to
apply the change.
In `@tests/discogs-plugin.spec.js`:
- Around line 214-244: The "scrape failed" branch doesn't validate anything
because the console listener is added after the scrape and no assertion is made;
move or add the console error listener setup (page.on('console', ...)) before
the scraping/import action (the code that resolves titleValue via
page.locator('input[name="titolo"]').inputValue()) and, in the else branch where
titleValue === '', assert that the collected logs array is empty (or contains
only expected messages) to fail the test if any JS errors were emitted; refer to
the existing symbols page, titleField/titleValue, logs and the
page.on('console', ...) handler to locate and update the test.
---
Outside diff comments:
In `@app/Controllers/LibraryThingImportController.php`:
- Around line 913-938: The Media→formato mapping only covers book-like labels,
so music rows (CD/LP/cassette) end up defaulting to 'cartaceo'; update the
$mediaMap used in parseLibraryThingRow (the block that sets $media, iterates
$mediaMap and assigns $result['formato']) to include common music labels (e.g.,
'cd', 'compact disc', 'cd audio', 'lp', 'vinile', 'vinyl', 'record', 'album',
'cassette', 'tape', etc.) mapped to the appropriate music formato value you use
(e.g., 'musica', 'cd', 'vinile' as per your conventions), and mirror the same
change in the other duplicated Media→formato mappings mentioned (the blocks
around the other occurrences) so formato is correctly set for music before
falling back to 'cartaceo'.
In `@app/Controllers/ScrapeController.php`:
- Around line 143-149: Move the call to ensureTipoMedia() so it runs before any
SBN enrichment and guard SBN enrichment on tipo_media === 'libro'; specifically,
call $this->ensureTipoMedia($payload) before the block that invokes
$this->enrichWithSbnData($payload, $cleanIsbn) and change the logic to only call
enrichWithSbnData when $payload['tipo_media'] === 'libro' (do the same
reordering and guard in the fallback branch that currently mirrors lines
213-219) so Discogs EANs won't be merged with book-only SBN fields.
In `@app/Models/BookRepository.php`:
- Around line 1003-1075: The updateOptionals path derives tipo_media from
formato/scraped_formato but never persists the formato column, causing format
metadata to be lost; inside the same method (e.g.,
BookRepository::updateOptionals) where you compute $formato and
$scrapedTipoMedia and set $cols['tipo_media'], also check hasColumn('formato')
and if formato/scraped_formato is non-empty and 'formato' is not already set in
$cols, set $cols['formato'] to the trimmed value (prefer $data['formato'] if
present, otherwise scraped_formato) so the raw format is stored alongside the
normalized tipo_media. Ensure you don't overwrite an existing $cols['formato'].
In `@app/Support/Updater.php`:
- Around line 2211-2237: The code currently logs missing bundled plugins and
continues; instead, when iterating BundledPlugins::LIST and a corresponding
$sourcePluginPath does not exist (after computing $pluginSlug via
normalizeBundledPluginSlug), stop the update and throw an exception (or return a
failure) so the update fails visibly; replace the debugLog('DEBUG', 'Plugin
bundled non presente nel pacchetto', ['plugin' => $pluginSlug]) + continue with
a thrown Exception (include the plugin slug and source path in the message) so
callers cannot proceed with a core upgrade that declares a bundled plugin absent
from $sourcePluginsDir.
---
Duplicate comments:
In `@app/Controllers/LibriApiController.php`:
- Line 35: The line setting $tipo_media in LibriApiController should guard
against array query params before casting: detect if ($q['tipo_media']) is an
array and pull a sensible scalar (e.g. the first element or implode) and then
trim/cast, otherwise cast as now; update the assignment that uses
$q['tipo_media'] so it mirrors the FrontendController's is_array guard to avoid
array-to-string warnings and broken filtering.
In `@app/Controllers/PluginController.php`:
- Around line 437-453: The Discogs plaintext api_token is being saved and then
exposed in the view because PluginManager::getSettings() decrypts it and index()
passes $pluginSettings into the template; update PluginController::index() to
mask/remove the 'api_token' value before rendering (e.g., replace the
'api_token' string with a boolean like 'has_token' based on whether it's
non-empty or unset the key) so the template never receives the decrypted token.
Locate the handling of $pluginSettings (after
$this->pluginManager->getSettings(...)) and add logic to detect the 'discogs'
plugin or the 'api_token' key and transform it to a masked indicator.
In `@app/Support/Updater.php`:
- Around line 2244-2280: The bundled-plugin directory swap is happening outside
the main rollback boundary so a partial failure can leave the plugin directory
at the new version; modify the updater to include bundled plugins in the main
backup/restore flow or postpone the final rename until the install commit:
either (A) extend backupAppFiles()/restoreAppFiles() to include
storage/plugins/<slug> (ensure backupAppFiles() records the same backupPath and
restoreAppFiles() can restore it) so copyDirectoryRecursive/rename of
targetPluginPath happens inside the rollback scope, or (B) change the flow in
installUpdate()/Updater::copyDirectoryRecursive block to copy plugin files into
a staging area and defer the rename of $stagingPath -> $targetPluginPath (and
removal of $backupPath) until after all other install steps succeed (so the
final swap is performed only once the install commits). Ensure references to
copyDirectoryRecursive, $stagingPath, $targetPluginPath, $backupPath,
backupAppFiles(), restoreAppFiles(), and installUpdate() are updated
accordingly.
In `@app/Views/admin/plugins.php`:
- Line 244: The settings URL is being built by concatenating
route_path('plugins') with "/{id}/settings"; replace both occurrences (the
data-settings-url at the lines referenced) to call the named settings route
instead (e.g. route_path('plugins.settings', ['id' => (int)$plugin['id']]) or
RouteTranslator::route('plugins.settings', ['id' => (int)$plugin['id']])) and
wrap that call with htmlspecialchars(..., ENT_QUOTES, 'UTF-8') just like before
so no hardcoded path segments remain.
In `@app/Views/frontend/catalog.php`:
- Around line 1308-1324: The sidebar isn’t reapplying the active class or
localized labels because tipo_media isn’t wired into the client-side filter
state; fix by exporting the same $tipoFilters PHP array to JS (serialize with
json_encode using JSON_HEX_TAG and JSON_HEX_APOS for safety) into a stable JS
variable/map and attach a stable DOM hook (e.g., data-filter="tipo_media" on the
filter container or each <a>) so updateFilter('tipo_media', ...) and
removeFilter('tipo_media') can look up the localized label and re-render the
active class exactly like the availability sync does; update the client-side
code that applies active classes and chips to consult this new tipo_media map
and the DOM hook when toggling filters.
In `@app/Views/libri/index.php`:
- Around line 688-697: The tooltip/title in the DataTable render (column data
'tipo_media' inside the render: function) currently shows raw enum keys; fix
this by emitting the PHP label map from MediaLabels::allTypes() into JS (use
json_encode(MediaLabels::allTypes(), JSON_HEX_TAG)) to create a labels map
variable, then change the render to use labels[data] || labels['libro'] for the
title and any places that display the human label (also update the active-filter
chip rendering referenced around the other usages noted). Ensure you replace raw
enum usage with the labels map in the render function and the other occurrences
mentioned (the similar spots at the other referenced lines).
In `@app/Views/libri/partials/book_form.php`:
- Around line 3615-3624: The code currently assigns raw data.tipo_media into the
select element; instead, fetch the canonical allowed keys from
MediaLabels::allTypes() (or a client-side equivalent whitelist),
normalize/transform incoming data.tipo_media to a canonical key (e.g.,
lowercase/trim or map known aliases) and only set
document.getElementById('tipo_media').value if the normalized value exists in
the whitelist; otherwise skip assignment or set a safe default. Update the
tipo_media assignment block (the try that references tipoMediaSelect and
data.tipo_media) to perform the whitelist check and normalization before
modifying the select.
In `@storage/plugins/discogs/DiscogsPlugin.php`:
- Around line 369-374: Compute a single authoritative media type and prefer an
explicit tipo_media over the inferred format: call inferTipoMedia as you do,
then set a single $resolvedMedia = $data['tipo_media'] ?? $resolvedType; finally
use $resolvedMedia in the Deezer gate (e.g. $isMusicSource = $resolvedMedia ===
'disco' || ($data['source'] ?? '') === 'discogs' || ($data['source'] ?? '') ===
'musicbrainz') so an explicit audiolibro in tipo_media is not overridden by
format or source.
In `@storage/plugins/discogs/plugin.json`:
- Around line 10-12: The plugin manifest advertises requires_app and
max_app_version but PluginManager currently only enforces requires_php; update
the runtime to enforce these fields or remove them from the manifest. Add checks
in the PluginManager class (e.g., the method that validates manifests such as
validatePluginManifest/validateRequirements or registerPlugin) to read
manifest['requires_app'] and manifest['max_app_version'] and use PHP's
version_compare to ensure the current app version satisfies requires_app (>=)
and is <= max_app_version before allowing install/activation; return/log a clear
error when the checks fail. Alternatively, if you don't want runtime enforcement
now, delete the requires_app and max_app_version keys from plugin.json so the
manifest doesn't promise unsupported gating.
In `@storage/plugins/discogs/README.md`:
- Around line 13-19: The documentation currently omits the intermediate Discogs
title/title+artist fallback between barcode search (GET
/database/search?barcode={ean}&type=release) and the MusicBrainz fallback;
update the "Strategia di ricerca" section to either re-add an explicit step
describing the Discogs title or title+artist fallback (e.g., "2a. Ricerca per
titolo o titolo+artista su Discogs -- GET
/database/search?query={title}+{artist}") or add a short note stating this step
was intentionally removed, and ensure the sequence numbering adjusts (barcode →
details → [title/title+artist fallback] → MusicBrainz → Deezer) so docs match PR
objectives and implementation.
In `@tests/discogs-plugin.spec.js`:
- Around line 64-80: The test assumes the discogs plugin is installed but may
run on a clean DB; update the test to handle installation before asserting
activation: after computing pluginExists (dbQuery) and locating discogsCard
(page.locator/... getByRole heading), if parseInt(pluginExists) === 0 then
interact with discogsCard to trigger the install flow (e.g., click the install
button within that card or navigate the install UI), wait for the install to
complete (waitForLoadState / waitForSelector for the "Attiva" or installation
confirmation) and re-query the DB (dbQuery) or pageContent to confirm the plugin
row exists before asserting the final expect; ensure the same pattern is applied
for the second block (lines 83-97) as well.
In `@tests/multisource-scraping.spec.js`:
- Around line 114-118: The test suite declares ID trackers (manualBookId,
importedBookId, manualDiscId, importedDisc1Id, importedDisc2Id) but afterAll()
only closes the browser; update afterAll() to remove the five created catalog
rows using those IDs — call the existing cleanup APIs or helpers (e.g.,
deleteBookById/deleteDiscById or the test harness catalog cleanup function) for
each non-zero ID in that list, handling errors gracefully so tests teardown the
RUN_TAG books/discs and avoid contaminating subsequent E2E runs; ensure you
reference and clear manualBookId, importedBookId, manualDiscId, importedDisc1Id,
and importedDisc2Id inside afterAll().
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 54d5292c-03a8-40a6-bfb3-16b5ee5bde74
📒 Files selected for processing (40)
.gitignoreapp/Controllers/CsvImportController.phpapp/Controllers/FrontendController.phpapp/Controllers/LibraryThingImportController.phpapp/Controllers/LibriApiController.phpapp/Controllers/LibriController.phpapp/Controllers/PluginController.phpapp/Controllers/PublicApiController.phpapp/Controllers/ScrapeController.phpapp/Models/BookRepository.phpapp/Routes/web.phpapp/Support/BundledPlugins.phpapp/Support/MediaLabels.phpapp/Support/PluginManager.phpapp/Support/RouteTranslator.phpapp/Support/Updater.phpapp/Views/admin/plugins.phpapp/Views/frontend/book-detail.phpapp/Views/frontend/catalog-grid.phpapp/Views/frontend/catalog.phpapp/Views/libri/index.phpapp/Views/libri/partials/book_form.phpapp/Views/libri/scheda_libro.phpinstaller/classes/Installer.phpinstaller/database/migrations/migrate_0.5.4.sqlinstaller/database/schema.sqllocale/de_DE.jsonlocale/en_US.jsonlocale/it_IT.jsonstorage/plugins/discogs/DiscogsPlugin.phpstorage/plugins/discogs/README.mdstorage/plugins/discogs/plugin.jsonstorage/plugins/discogs/views/settings.phpstorage/plugins/discogs/wrapper.phptests/discogs-advanced.spec.jstests/discogs-import.spec.jstests/discogs-plugin.spec.jstests/multisource-scraping.spec.jstests/playwright.config.jsversion.json
| <p class="related-book-author"> | ||
| <?= htmlspecialchars($related['autori'] ?? __('Autore sconosciuto'), ENT_QUOTES, 'UTF-8') ?> | ||
| <?= htmlspecialchars($related['autori'] ?? __($relatedIsMusic ? 'Artista sconosciuto' : 'Autore sconosciuto'), ENT_QUOTES, 'UTF-8') ?> |
There was a problem hiding this comment.
Use the unknown-author fallback when autori is blank, not only when it is null.
?? leaves '' untouched, so related cards can still render an empty author line instead of the new music-aware fallback.
💡 Minimal fix
- <?= htmlspecialchars($related['autori'] ?? __($relatedIsMusic ? 'Artista sconosciuto' : 'Autore sconosciuto'), ENT_QUOTES, 'UTF-8') ?>
+ <?php $relatedAuthorsLabel = trim((string) ($related['autori'] ?? '')); ?>
+ <?= htmlspecialchars(
+ $relatedAuthorsLabel !== '' ? $relatedAuthorsLabel : __($relatedIsMusic ? 'Artista sconosciuto' : 'Autore sconosciuto'),
+ ENT_QUOTES,
+ 'UTF-8'
+ ) ?>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/Views/frontend/book-detail.php` around lines 2239 - 2240, The view
currently uses htmlspecialchars($related['autori'] ?? __($relatedIsMusic ?
'Artista sconosciuto' : 'Autore sconosciuto'), ...) which only falls back when
autori is null but leaves empty strings; change this to explicitly treat
empty/whitespace as missing by checking trim($related['autori'] ?? '') (or empty
after trim) and using the music-aware fallback (__($relatedIsMusic ? 'Artista
sconosciuto' : 'Autore sconosciuto')) when that check fails; update the
expression around htmlspecialchars in the related-book-author card so empty
autori strings render the fallback.
| <?php if (($book['tipo_media'] ?? 'libro') !== 'libro'): ?> | ||
| <span class="book-media-badge" title="<?= htmlspecialchars(\App\Support\MediaLabels::tipoMediaDisplayName($book['tipo_media']), ENT_QUOTES, 'UTF-8') ?>"> | ||
| <i class="fas <?= htmlspecialchars(\App\Support\MediaLabels::icon($book['tipo_media']), ENT_QUOTES, 'UTF-8') ?>"></i> | ||
| </span> |
There was a problem hiding this comment.
Give the media badge an accessible name.
This is icon-only UI, and title is not a reliable accessible label. Add aria-label or visually hidden text on the wrapper and mark the <i> as decorative so assistive tech can distinguish non-book media in the grid.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/Views/frontend/catalog-grid.php` around lines 41 - 44, The media badge
span (class "book-media-badge") is currently icon-only and uses title, which
isn't sufficient for accessibility; update the span that uses
MediaLabels::tipoMediaDisplayName($book['tipo_media']) to include an accessible
name (e.g., add aria-label with that display name or add visually hidden text
inside the span) and mark the <i> icon produced by
MediaLabels::icon($book['tipo_media']) as decorative (e.g., aria-hidden="true"
on the <i>) so screen readers announce the media label from the span rather than
the icon.
| $apiToken = trim($_POST['api_token'] ?? ''); | ||
| if ($apiToken !== '') { | ||
| $settings = ['api_token' => $apiToken]; | ||
| if ($plugin->saveSettings($settings)) { | ||
| $successMessage = __('Impostazioni Discogs salvate correttamente.'); | ||
| } else { | ||
| $errorMessage = __('Errore nel salvataggio delle impostazioni.'); | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n storage/plugins/discogs/views/settings.phpRepository: fabiodalez-dev/Pinakes
Length of output: 9898
🏁 Script executed:
# Search for similar form handling patterns with POST data
rg '\$_POST\[' --type php -B 2 -A 2 | head -100Repository: fabiodalez-dev/Pinakes
Length of output: 7451
🏁 Script executed:
# Look for validation patterns in the codebase
rg 'is_string.*\$_POST' --type phpRepository: fabiodalez-dev/Pinakes
Length of output: 48
Add type validation for api_token before calling trim().
$_POST['api_token'] can be submitted as an array (e.g., api_token[]=x), causing trim() to throw a TypeError and crash the settings page with a 500 error instead of a validation error.
Suggested fix
- $apiToken = trim($_POST['api_token'] ?? '');
- if ($apiToken !== '') {
- $settings = ['api_token' => $apiToken];
- if ($plugin->saveSettings($settings)) {
- $successMessage = __('Impostazioni Discogs salvate correttamente.');
- } else {
- $errorMessage = __('Errore nel salvataggio delle impostazioni.');
- }
- }
+ $rawApiToken = $_POST['api_token'] ?? '';
+ if (!is_string($rawApiToken)) {
+ $errorMessage = __('Token Discogs non valido.');
+ } else {
+ $apiToken = trim($rawApiToken);
+ if ($apiToken !== '') {
+ $settings = ['api_token' => $apiToken];
+ if ($plugin->saveSettings($settings)) {
+ $successMessage = __('Impostazioni Discogs salvate correttamente.');
+ } else {
+ $errorMessage = __('Errore nel salvataggio delle impostazioni.');
+ }
+ }
+ }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@storage/plugins/discogs/views/settings.php` around lines 23 - 31, Validate
that $_POST['api_token'] is a string before calling trim(): check
is_string($_POST['api_token']) (or cast only when safe) and if not, set
$apiToken = '' and populate $errorMessage with a validation error instead of
calling trim on an array or other type; then proceed to build $settings and call
$plugin->saveSettings($settings) only when $apiToken is a valid string. Ensure
this check is done before the existing lines that define $apiToken, $settings,
and call $plugin->saveSettings so the page returns a user-friendly validation
error rather than throwing a TypeError.
| test.afterAll(async () => { | ||
| // Cleanup test data | ||
| try { | ||
| if (createdId !== '') { | ||
| dbExec(`DELETE FROM libri WHERE id = ${Number(createdId)} AND deleted_at IS NULL`); | ||
| } | ||
| } catch {} |
There was a problem hiding this comment.
Don't hard-delete libri here without cleaning related data.
Saving through the admin form also creates copy rows. A raw DELETE FROM libri can fail on FK constraints or orphan copie, and the empty catch {} hides when cleanup did nothing. Use the app's normal delete/soft-delete path, or remove dependent rows first and assert cleanup succeeded.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tests/discogs-import.spec.js` around lines 51 - 57, The cleanup currently
does a raw DELETE via dbExec on the libri table inside test.afterAll (using
createdId) which can violate FK constraints and the empty catch hides failures;
instead call the app's normal delete/soft-delete path (e.g., use the same
controller/service used by the admin form) or explicitly delete dependent copie
rows first (DELETE FROM copie WHERE libro_id = ...) then soft-delete libri, and
replace the empty catch with an assertion or a logged/raised error so test
cleanup failures are visible; locate the cleanup in test.afterAll, the createdId
variable, and the dbExec call to apply the change.
| // Check EAN field has the barcode | ||
| const eanValue = await page.locator('input[name="ean"]').inputValue(); | ||
| // The barcode might be in isbn13 or ean depending on the scraper | ||
| const isbn13Value = await page.locator('input[name="isbn13"]').inputValue(); | ||
| expect(eanValue === TEST_BARCODE || isbn13Value === TEST_BARCODE || | ||
| eanValue.includes('720642442524') || isbn13Value.includes('720642442524')).toBe(true); |
There was a problem hiding this comment.
Replace the scraped public barcode before saving the test row.
TEST_BARCODE is a real release identifier and ean / isbn13 are unique. Persisting it unchanged makes this create flow fail or mutate shared E2E data if that barcode already exists. Rewrite the imported identifier to a run-scoped value before submit, like the multisource suite does.
Also applies to: 123-148
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tests/discogs-import.spec.js` around lines 115 - 120, The test currently
asserts the real public barcode (TEST_BARCODE) is present in the scraped inputs
'input[name="ean"]' or 'input[name="isbn13"]', which can persist a shared
identifier; before submitting the imported row you should overwrite the scraped
identifier with a run-scoped value: locate the fields using
page.locator('input[name="ean"]') and page.locator('input[name="isbn13"]') and
set (fill) them to a generated unique barcode/string (same pattern used by the
multisource suite) so the form saves a test-specific identifier; apply this
replacement wherever the test reads/writes ean/isbn13 in the block around the
expect (also for the related checks in lines 123-148).
| // Wait for response (up to 15s — Discogs can be slow) | ||
| await page.waitForTimeout(5000); | ||
|
|
||
| // Check if any fields were populated | ||
| const titleField = page.locator('input[name="titolo"]'); | ||
| const titleValue = await titleField.inputValue().catch(() => ''); | ||
|
|
||
| if (titleValue !== '') { | ||
| // Scraping succeeded — verify some data | ||
| expect(titleValue.length).toBeGreaterThan(0); | ||
|
|
||
| // Check if format was set to a music type | ||
| const formatField = page.locator('input[name="formato"]'); | ||
| const formatValue = await formatField.inputValue().catch(() => ''); | ||
| // Format might be populated from Discogs | ||
|
|
||
| // Check description (should contain tracklist) | ||
| const descFrame = page.frameLocator('.tox-edit-area__iframe').first(); | ||
| if (await descFrame.locator('body').isVisible({ timeout: 2000 }).catch(() => false)) { | ||
| const descText = await descFrame.locator('body').textContent().catch(() => ''); | ||
| // If Discogs returned tracklist, description should have content | ||
| if (descText) { | ||
| expect(descText.length).toBeGreaterThan(0); | ||
| } | ||
| } | ||
| } else { | ||
| // Scraping might have failed (rate limit, network) — that's OK for CI | ||
| // Just verify no JS errors occurred | ||
| const logs = []; | ||
| page.on('console', msg => { if (msg.type() === 'error') logs.push(msg.text()); }); | ||
| } |
There was a problem hiding this comment.
The “scrape failed” branch currently proves nothing.
The console listener is attached after the import attempt, and logs is never asserted. A JS error or a slow Discogs/MusicBrainz response can therefore still pass green without validating either success or the expected failure state.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tests/discogs-plugin.spec.js` around lines 214 - 244, The "scrape failed"
branch doesn't validate anything because the console listener is added after the
scrape and no assertion is made; move or add the console error listener setup
(page.on('console', ...)) before the scraping/import action (the code that
resolves titleValue via page.locator('input[name="titolo"]').inputValue()) and,
in the else branch where titleValue === '', assert that the collected logs array
is empty (or contains only expected messages) to fail the test if any JS errors
were emitted; refer to the existing symbols page, titleField/titleValue, logs
and the page.on('console', ...) handler to locate and update the test.
There was a problem hiding this comment.
Actionable comments posted: 5
♻️ Duplicate comments (5)
storage/plugins/discogs/DiscogsPlugin.php (1)
269-273:⚠️ Potential issue | 🟠 MajorPass the validated barcode into the MusicBrainz fallback.
This branch is reached from a barcode lookup, but
nulldrops the only validated EAN if the MusicBrainz detail payload does not echo it back.Minimal fix
- $mbResult = $this->searchMusicBrainz($isbn, $token, null); + $mbResult = $this->searchMusicBrainz($isbn, $token, $isbn);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@storage/plugins/discogs/DiscogsPlugin.php` around lines 269 - 273, The MusicBrainz fallback call uses null for the barcode param which drops the validated EAN; change the third argument of searchMusicBrainz in this branch to pass the validated barcode variable (e.g. $isbn or the validatedBarcode variable used earlier) instead of null so the validated EAN is preserved when calling searchMusicBrainz before merging via mergeBookData($currentResult, $mbResult).app/Controllers/PluginController.php (1)
441-453:⚠️ Potential issue | 🟠 MajorPersisting
api_tokenhere still needs the admin-list redaction path.
index()still only stripsgoogle_books_api_keyandapi_keybefore$pluginSettingsis handed toapp/Views/admin/plugins.php, so adding Discogs token persistence here leaves the new secret available to the template scope.Minimal follow-up in
index()if ($plugin['name'] === 'api-book-scraper' && array_key_exists('api_key', $settings)) { $settings['api_key_exists'] = $settings['api_key'] !== '' && $settings['api_key'] !== '••••••••'; $settings['api_key'] = $settings['api_key_exists'] ? '••••••••' : ''; } + + if ($plugin['name'] === 'discogs' && array_key_exists('api_token', $settings)) { + $settings['api_token_exists'] = $settings['api_token'] !== ''; + $settings['api_token'] = $settings['api_token_exists'] ? '••••••••' : ''; + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Controllers/PluginController.php` around lines 441 - 453, The code persists the Discogs secret under the 'api_token' key via PluginController::setSetting but PluginController::index() still only redacts 'google_books_api_key' and 'api_key' before passing $pluginSettings to the admin/plugins.php view, leaving the newly saved Discogs token exposed; update index() to also redact the Discogs token key used by this plugin (the 'api_token' entry saved via $this->pluginManager->setSetting($pluginId, 'api_token', ...)) or replace it with a boolean like 'has_token' before rendering so templates never receive the raw secret (ensure the same identifying symbol names: PluginController::index(), $pluginSettings, 'api_token', admin/plugins.php).app/Controllers/LibriController.php (1)
3041-3041:⚠️ Potential issue | 🟠 MajorDon’t fabricate
libroin exported CSV rows.If
tipo_mediais absent in the exported row, this writes an explicitlibroand prevents a later CSV import from preserving or inferring the real media type fromformato. Emit an empty field instead.♻️ Proposed fix
- $libro['tipo_media'] ?? 'libro', + $libro['tipo_media'] ?? '',🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Controllers/LibriController.php` at line 3041, In the CSV export in LibriController where the $libro array is serialized (look for the CSV export block referencing $libro and the 'tipo_media' key), stop defaulting missing tipo_media to the literal 'libro' and emit an empty field instead; change the expression that uses $libro['tipo_media'] ?? 'libro' so it returns an empty string when tipo_media is absent (preserving downstream import/format inference from formato).installer/database/migrations/migrate_0.5.4.sql (1)
67-86:⚠️ Potential issue | 🟠 MajorKeep the backfill rules aligned with runtime media normalization.
This still misses aliases that runtime already accepts as non-books (
album,record,disc,movie,film) and separator variants likeaudio-book/audio_book. Rows using those values will staylibroor be misclassified during upgrade, so migrated catalogs can disagree withApp\Support\MediaLabels::inferTipoMedia()and with new imports.♻️ Proposed fix
UPDATE libri SET tipo_media = 'disco' WHERE tipo_media = 'libro' - AND (LOWER(formato) LIKE '%cd%' OR LOWER(formato) LIKE '%compact disc%' - OR LOWER(formato) LIKE '%vinyl%' OR LOWER(formato) LIKE '%vinile%' - OR LOWER(formato) LIKE '%lp%' OR LOWER(formato) LIKE '%cassett%' - OR LOWER(formato) LIKE '%audio cassetta%' OR LOWER(formato) LIKE '%audio-cassetta%' - OR LOWER(formato) LIKE '%audiocassetta%' - OR LOWER(formato) REGEXP '[[:<:]]music[[:>:]]' - OR LOWER(formato) REGEXP '[[:<:]]musik[[:>:]]') - AND LOWER(formato) NOT LIKE '%audiolibro%' AND LOWER(formato) NOT LIKE '%audiobook%'; + AND LOWER(formato) REGEXP '(^|[^[:alnum:]_])(disc|record|album|cd|lp|music|musik)([^[:alnum:]_]|$)|compact[ _-]?disc|vinyl|vinile|cassett' + AND LOWER(formato) NOT REGEXP 'audiolibro|audio[ _-]?book'; UPDATE libri SET tipo_media = 'audiolibro' WHERE tipo_media = 'libro' - AND (LOWER(formato) LIKE '%audiolibro%' OR LOWER(formato) LIKE '%audiobook%' OR LOWER(formato) LIKE '%audio book%'); + AND LOWER(formato) REGEXP 'audiolibro|audio[ _-]?book'; UPDATE libri SET tipo_media = 'dvd' WHERE tipo_media = 'libro' - AND (LOWER(formato) LIKE '%dvd%' OR LOWER(formato) LIKE '%blu-ray%' - OR LOWER(formato) LIKE '%blu_ray%' OR LOWER(formato) LIKE '%blu ray%' - OR LOWER(formato) LIKE '%bluray%'); + AND LOWER(formato) REGEXP 'dvd|blu[ _-]?ray|movie|film';🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@installer/database/migrations/migrate_0.5.4.sql` around lines 67 - 86, The UPDATE backfill queries on table libri that set tipo_media based on LOWER(formato) need to be expanded to match the same aliases and separators used by App\Support\MediaLabels::inferTipoMedia(): add alternative terms 'album', 'record', 'disc' to the disco/music rule and 'movie', 'film' to the dvd rule, and support separator variants like 'audio-book' and 'audio_book' (and 'blu-ray' / 'blu_ray') in the LIKE/REGEXP checks so that rows where formato contains those terms are reclassified (keep the existing exclusions for audiolibro/audiobook when classifying disco/music). Ensure you update the same three UPDATE statements that operate on libri.tipo_media when tipo_media = 'libro' so the migration aligns with runtime normalization.app/Controllers/LibraryThingImportController.php (1)
936-939:⚠️ Potential issue | 🟡 MinorUnrecognized Media values can still overwrite existing
tipo_media.The past review concern is only partially addressed. When Media is empty,
tipo_mediais correctly set tonull. However, when Media contains an unrecognized value (e.g., "Magazine", "Comic"),inferTipoMedia()defaults to'libro', which then passes throughnormalizeTipoMedia()inupdateBook()and overwrites existingtipo_mediavalues like'disco'or'dvd'.For consistency with
CsvImportController(which usesnormalizeTipoMedia()for parsing), consider usingnormalizeTipoMedia()here so unrecognized values returnnulland theCOALESCE(?, tipo_media)in updates preserves existing values.💡 Suggested fix
- $result['tipo_media'] = $mediaRaw !== '' ? \App\Support\MediaLabels::inferTipoMedia($mediaRaw) : null; + $result['tipo_media'] = $mediaRaw !== '' ? \App\Support\MediaLabels::normalizeTipoMedia($mediaRaw) : null;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Controllers/LibraryThingImportController.php` around lines 936 - 939, The current code in LibraryThingImportController sets tipo_media using \App\Support\MediaLabels::inferTipoMedia($mediaRaw), but inferTipoMedia maps unrecognized values to 'libro' and will overwrite existing tipo_media; change this to use \App\Support\MediaLabels::normalizeTipoMedia($mediaRaw) (the same normalization CsvImportController uses) so unknown Media strings return null and the subsequent updateBook() COALESCE(?, tipo_media) preserves existing values; update the assignment of $result['tipo_media'] to call normalizeTipoMedia when $mediaRaw !== '' and keep null for empty strings.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/Controllers/LibriController.php`:
- Line 1220: The code is always seeding 'tipo_media' => '' into the update
payload which causes BookRepository::update (which checks for key existence) to
reset stored enum values; instead, stop adding the 'tipo_media' key unless the
incoming request actually provided it. Locate the payload-building code in
LibriController (where 'tipo_media' => '' is set) and change it to conditionally
add 'tipo_media' only when the Request contains that field (e.g., use
$request->has('tipo_media') or similar) so updates that omit the field do not
include the key; no change needed to BookRepository other than ensuring it
continues to treat key presence as intent to update.
In `@app/Support/Updater.php`:
- Around line 2246-2278: The code currently throws if removing the old backup
after a successful swap fails; make deletion of the backup a best-effort
operation so a post-swap cleanup error cannot abort an otherwise-successful
plugin activation. Specifically, in Updater.php where rename($stagingPath,
$targetPluginPath) succeeds and you call removeDirectoryTree($backupPath), wrap
removeDirectoryTree($backupPath) in its own try/catch (or check returns) and on
failure call $this->debugLog(...) instead of throwing; keep the existing restore
logic in the outer catch that attempts to rename($backupPath, $targetPluginPath)
only when needed. This ensures copyDirectoryRecursive, rename($targetPluginPath,
$backupPath), rename($stagingPath, $targetPluginPath) remain unchanged while
removeDirectoryTree($backupPath) is non-fatal.
- Around line 2296-2325: The cleanup fails on symlinks; update
removeDirectoryTree to explicitly detect and unlink symlinks before treating
entries as files/dirs: inside the foreach over the
RecursiveIteratorIterator(RecursiveDirectoryIterator(...)) check $item->isLink()
first and call unlink($item->getPathname()) (and continue) so symlinked entries
aren’t passed to isDir()/rmdir; also handle the root $path by checking
is_link($path) and calling unlink($path) instead of rmdir when appropriate.
Ensure you reference removeDirectoryTree,
RecursiveDirectoryIterator/RecursiveIteratorIterator, isLink/is_link, unlink and
rmdir when applying the fix.
In `@storage/plugins/discogs/DiscogsPlugin.php`:
- Around line 310-316: The fallback artist extraction in DiscogsPlugin uses
$currentResult['authors'] directly, which leads to the string "Array" when
entries are structured (e.g. ['name'=>'...']). Update the logic in the block
handling $currentResult['authors'] so that when authors is an array you inspect
the first element: if it's a scalar use it, if it's an array look for a 'name'
key (or any scalar field) and use that, falling back to a string cast only if
necessary; always trim the chosen value and assign it to $artist before building
the Discogs fallback query so the query uses a real artist name instead of
"Array".
- Around line 934-939: mergeBookData currently hardcodes 'discogs' as the source
when calling \App\Support\BookDataMerger::merge, which misattributes upstream
data; instead determine the real source from the payload (prefer $new's source,
then $existing's source) and pass that source string as the third argument to
BookDataMerger::merge (fall back to 'discogs' only if neither payload supplies a
source). Update the mergeBookData method to compute the source variable from
$new and $existing and pass it into BookDataMerger::merge.
---
Duplicate comments:
In `@app/Controllers/LibraryThingImportController.php`:
- Around line 936-939: The current code in LibraryThingImportController sets
tipo_media using \App\Support\MediaLabels::inferTipoMedia($mediaRaw), but
inferTipoMedia maps unrecognized values to 'libro' and will overwrite existing
tipo_media; change this to use
\App\Support\MediaLabels::normalizeTipoMedia($mediaRaw) (the same normalization
CsvImportController uses) so unknown Media strings return null and the
subsequent updateBook() COALESCE(?, tipo_media) preserves existing values;
update the assignment of $result['tipo_media'] to call normalizeTipoMedia when
$mediaRaw !== '' and keep null for empty strings.
In `@app/Controllers/LibriController.php`:
- Line 3041: In the CSV export in LibriController where the $libro array is
serialized (look for the CSV export block referencing $libro and the
'tipo_media' key), stop defaulting missing tipo_media to the literal 'libro' and
emit an empty field instead; change the expression that uses
$libro['tipo_media'] ?? 'libro' so it returns an empty string when tipo_media is
absent (preserving downstream import/format inference from formato).
In `@app/Controllers/PluginController.php`:
- Around line 441-453: The code persists the Discogs secret under the
'api_token' key via PluginController::setSetting but PluginController::index()
still only redacts 'google_books_api_key' and 'api_key' before passing
$pluginSettings to the admin/plugins.php view, leaving the newly saved Discogs
token exposed; update index() to also redact the Discogs token key used by this
plugin (the 'api_token' entry saved via
$this->pluginManager->setSetting($pluginId, 'api_token', ...)) or replace it
with a boolean like 'has_token' before rendering so templates never receive the
raw secret (ensure the same identifying symbol names: PluginController::index(),
$pluginSettings, 'api_token', admin/plugins.php).
In `@installer/database/migrations/migrate_0.5.4.sql`:
- Around line 67-86: The UPDATE backfill queries on table libri that set
tipo_media based on LOWER(formato) need to be expanded to match the same aliases
and separators used by App\Support\MediaLabels::inferTipoMedia(): add
alternative terms 'album', 'record', 'disc' to the disco/music rule and 'movie',
'film' to the dvd rule, and support separator variants like 'audio-book' and
'audio_book' (and 'blu-ray' / 'blu_ray') in the LIKE/REGEXP checks so that rows
where formato contains those terms are reclassified (keep the existing
exclusions for audiolibro/audiobook when classifying disco/music). Ensure you
update the same three UPDATE statements that operate on libri.tipo_media when
tipo_media = 'libro' so the migration aligns with runtime normalization.
In `@storage/plugins/discogs/DiscogsPlugin.php`:
- Around line 269-273: The MusicBrainz fallback call uses null for the barcode
param which drops the validated EAN; change the third argument of
searchMusicBrainz in this branch to pass the validated barcode variable (e.g.
$isbn or the validatedBarcode variable used earlier) instead of null so the
validated EAN is preserved when calling searchMusicBrainz before merging via
mergeBookData($currentResult, $mbResult).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: acbd1d93-aea8-4b15-ad5c-048b2a915b09
📒 Files selected for processing (10)
app/Controllers/CsvImportController.phpapp/Controllers/LibraryThingImportController.phpapp/Controllers/LibriController.phpapp/Controllers/PluginController.phpapp/Models/BookRepository.phpapp/Support/MediaLabels.phpapp/Support/Updater.phpinstaller/database/migrations/migrate_0.5.4.sqlstorage/plugins/discogs/DiscogsPlugin.phpversion.json
✅ Files skipped from review due to trivial changes (1)
- version.json
🚧 Files skipped from review as they are similar to previous changes (1)
- app/Models/BookRepository.php
| private function removeDirectoryTree(string $path): void | ||
| { | ||
| if (!file_exists($path)) { | ||
| return; | ||
| } | ||
| if (!is_dir($path)) { | ||
| throw new Exception(sprintf(__('Percorso plugin non valido: %s'), $path)); | ||
| } | ||
|
|
||
| $iterator = new \RecursiveIteratorIterator( | ||
| new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS), | ||
| \RecursiveIteratorIterator::CHILD_FIRST | ||
| ); | ||
|
|
||
| foreach ($iterator as $item) { | ||
| if ($item->isDir()) { | ||
| if (!rmdir($item->getPathname())) { | ||
| throw new Exception(sprintf(__('Impossibile rimuovere directory: %s'), $item->getPathname())); | ||
| } | ||
| } else { | ||
| if (!unlink($item->getPathname())) { | ||
| throw new Exception(sprintf(__('Impossibile rimuovere file: %s'), $item->getPathname())); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (!rmdir($path)) { | ||
| throw new Exception(sprintf(__('Impossibile rimuovere directory: %s'), $path)); | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
In PHP, when RecursiveDirectoryIterator is created with a symlinked directory path and no FOLLOW_SYMLINKS flag, does it enumerate the target directory contents, and does SplFileInfo::isDir() return true for symlinks to directories?
💡 Result:
No, when RecursiveDirectoryIterator is created with a symlinked directory path without the FilesystemIterator::FOLLOW_SYMLINKS flag, it does not enumerate the target directory contents—it iterates over the symlink as if it were a non-directory file (a leaf node). Yes, SplFileInfo::isDir returns true for symlinks to directories.
Citations:
- 1: https://bugs.php.net/48788
- 2: https://bugs.php.net/bug.php?edit=3&id=48788
- 3: https://www.php.net/manual/en/class.recursivedirectoryiterator.php
- 4: https://www.php.net/manual/en/splfileinfo.isdir.php
- 5: https://bugs.php.net/bug.php?id=72364
🏁 Script executed:
#!/bin/bash
# Search for PHP symlink handling documentation and verify behavior
python3 << 'EOF'
import urllib.request
import json
# Check if SplFileInfo has isLink() method by examining PHP docs
queries = [
"SplFileInfo isLink method PHP",
"PHP rmdir symlink behavior",
"PHP unlink symlink directory"
]
for query in queries:
print(f"Query: {query}")
print("-" * 50)
# We'll rely on documented PHP behavior
print()
EOFRepository: fabiodalez-dev/Pinakes
Length of output: 328
🌐 Web query:
Does SplFileInfo in PHP have an isLink() method, and what is the correct way to detect symlinks when iterating with RecursiveDirectoryIterator?
💡 Result:
Yes, SplFileInfo in PHP has an isLink method that returns true if the file is a symbolic link. The correct way to detect symlinks when iterating with RecursiveDirectoryIterator is to use $file->isLink on each SplFileInfo object returned by the iterator. Example: $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator('/path')); foreach ($iterator as $file) { if ($file->isLink) { echo $file->getPathname . ' is a symlink'; } } Note: RecursiveDirectoryIterator returns SplFileInfo objects by default (CURRENT_AS_FILEINFO), which support isLink. Use FilesystemIterator::FOLLOW_SYMLINKS flag if you want to follow symlinks during recursion, but isLink detects the symlink itself regardless.
Citations:
- 1: https://www.php.net/manual/en/splfileinfo.islink.php
- 2: https://www.php.net/manual/en/class.splfileinfo.php
- 3: https://www.php.net/manual/en/class.recursivedirectoryiterator.php
🌐 Web query:
What happens when you call rmdir() on a PHP symlink to a directory versus using unlink()?
💡 Result:
When calling rmdir or unlink on a PHP symlink pointing to a directory, the behavior differs by operating system, as PHP's filesystem functions follow underlying platform semantics. On Unix/Linux systems: - unlink($symlink) deletes the symlink itself successfully. - rmdir($symlink) fails, typically because the symlink appears as a non-empty directory (it points to a directory, which PHP's is_dir detects as true). On Windows: - unlink($symlink) fails with a "Permission denied" warning. - rmdir($symlink) deletes the symlink itself successfully. In both cases, neither function affects the target directory—only the symlink is removed. This platform difference is explicitly noted in the official PHP unlink documentation: "If the file is a symlink, the symlink will be deleted. On Windows, to delete a symlink to a directory, rmdir has to be used instead." For cross-platform code, detect the OS (e.g., via PHP_OS_FAMILY) and choose the appropriate function, or use is_link combined with platform checks.
Citations:
- 1: https://www.php.net/manual/en/function.unlink.php
- 2: https://www.php.net/manual/en/function.unlink
- 3: https://bugs.php.net/52176
- 4: https://www.php.net/manual/en/function.symlink.php
- 5: https://stackoverflow.com/questions/18262555/remove-a-symlink-with-php-on-windows
Explicit symlink handling required to prevent cleanup failures.
The RecursiveDirectoryIterator without FOLLOW_SYMLINKS doesn't traverse into symlinked directories, but it will encounter symlinks in the iteration. On Unix/Linux systems, calling rmdir() on a symlink to a directory fails because the symlink appears as a non-directory entity, causing an exception. The fix correctly detects symlinks using isLink() and removes them explicitly with unlink() before attempting directory removal operations, preventing cleanup from failing when symlinks exist in the plugin directory.
Proposed fix
private function removeDirectoryTree(string $path): void
{
- if (!file_exists($path)) {
+ if (!file_exists($path) && !is_link($path)) {
return;
}
+ if (is_link($path)) {
+ if (!unlink($path)) {
+ throw new Exception(sprintf(__('Impossibile rimuovere link simbolico: %s'), $path));
+ }
+ return;
+ }
if (!is_dir($path)) {
throw new Exception(sprintf(__('Percorso plugin non valido: %s'), $path));
}
@@
foreach ($iterator as $item) {
+ if ($item->isLink()) {
+ if (!unlink($item->getPathname())) {
+ throw new Exception(sprintf(__('Impossibile rimuovere link simbolico: %s'), $item->getPathname()));
+ }
+ continue;
+ }
if ($item->isDir()) {
if (!rmdir($item->getPathname())) {
throw new Exception(sprintf(__('Impossibile rimuovere directory: %s'), $item->getPathname()));
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| private function removeDirectoryTree(string $path): void | |
| { | |
| if (!file_exists($path)) { | |
| return; | |
| } | |
| if (!is_dir($path)) { | |
| throw new Exception(sprintf(__('Percorso plugin non valido: %s'), $path)); | |
| } | |
| $iterator = new \RecursiveIteratorIterator( | |
| new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS), | |
| \RecursiveIteratorIterator::CHILD_FIRST | |
| ); | |
| foreach ($iterator as $item) { | |
| if ($item->isDir()) { | |
| if (!rmdir($item->getPathname())) { | |
| throw new Exception(sprintf(__('Impossibile rimuovere directory: %s'), $item->getPathname())); | |
| } | |
| } else { | |
| if (!unlink($item->getPathname())) { | |
| throw new Exception(sprintf(__('Impossibile rimuovere file: %s'), $item->getPathname())); | |
| } | |
| } | |
| } | |
| if (!rmdir($path)) { | |
| throw new Exception(sprintf(__('Impossibile rimuovere directory: %s'), $path)); | |
| } | |
| } | |
| private function removeDirectoryTree(string $path): void | |
| { | |
| if (!file_exists($path) && !is_link($path)) { | |
| return; | |
| } | |
| if (is_link($path)) { | |
| if (!unlink($path)) { | |
| throw new Exception(sprintf(__('Impossibile rimuovere link simbolico: %s'), $path)); | |
| } | |
| return; | |
| } | |
| if (!is_dir($path)) { | |
| throw new Exception(sprintf(__('Percorso plugin non valido: %s'), $path)); | |
| } | |
| $iterator = new \RecursiveIteratorIterator( | |
| new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS), | |
| \RecursiveIteratorIterator::CHILD_FIRST | |
| ); | |
| foreach ($iterator as $item) { | |
| if ($item->isLink()) { | |
| if (!unlink($item->getPathname())) { | |
| throw new Exception(sprintf(__('Impossibile rimuovere link simbolico: %s'), $item->getPathname())); | |
| } | |
| continue; | |
| } | |
| if ($item->isDir()) { | |
| if (!rmdir($item->getPathname())) { | |
| throw new Exception(sprintf(__('Impossibile rimuovere directory: %s'), $item->getPathname())); | |
| } | |
| } else { | |
| if (!unlink($item->getPathname())) { | |
| throw new Exception(sprintf(__('Impossibile rimuovere file: %s'), $item->getPathname())); | |
| } | |
| } | |
| } | |
| if (!rmdir($path)) { | |
| throw new Exception(sprintf(__('Impossibile rimuovere directory: %s'), $path)); | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/Support/Updater.php` around lines 2296 - 2325, The cleanup fails on
symlinks; update removeDirectoryTree to explicitly detect and unlink symlinks
before treating entries as files/dirs: inside the foreach over the
RecursiveIteratorIterator(RecursiveDirectoryIterator(...)) check $item->isLink()
first and call unlink($item->getPathname()) (and continue) so symlinked entries
aren’t passed to isDir()/rmdir; also handle the root $path by checking
is_link($path) and calling unlink($path) instead of rmdir when appropriate.
Ensure you reference removeDirectoryTree,
RecursiveDirectoryIterator/RecursiveIteratorIterator, isLink/is_link, unlink and
rmdir when applying the fix.
- MusicBrainz fallback: pass $isbn (validated barcode) instead of null - PluginController index: redact Discogs api_token before template - CSV export: don't fabricate 'libro' for empty tipo_media
Tests: 1. tipo_media ENUM column exists with all values 2. Book form has tipo_media dropdown 3. Admin list has media type icon column 4. API filters by tipo_media=disco 5. CD shows music-specific labels 6. Book shows standard labels 7. Edit CD preserves tipo_media=disco 8. CSV export includes tipo_media 9. Format display name (cd_audio → CD Audio) 10. Discogs plugin registered as bundled PHPStan level 5 fixes: - instantiatePlugin() returns object (not ?object) - Remove redundant is_object() and null checks
- Discogs plugin: merge genres + styles into parole_chiave (was: only styles, now: "Rock, Alternative Rock, Grunge") - Form JS: handle data.parole_chiave alias alongside data.keywords - BookRepository updateOptionals: add parole_chiave to optional columns (was missing — scraped keywords never persisted via enrichment path)
|
@CodeRabbit review |
|
@CodeRabbit full review |
✅ Actions performedFull review triggered. |
There was a problem hiding this comment.
Actionable comments posted: 9
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
app/Support/PluginManager.php (1)
47-52:⚠️ Potential issue | 🟠 MajorRemove optional plugins from auto-registration or gate activation with metadata.
BundledPlugins::LISTincludesdiscogs, a network-backed music scraper that should remain opt-in. TheautoRegisterBundledPlugins()method inserts all bundled plugins withis_active = 1hardcoded (line 114) and immediately callsonActivate()hooks, bypassing any opt-in check. On first load or DB rebuild, Discogs will be auto-activated without user consent, enabling external API calls to Discogs, MusicBrainz, and Deezer.Either remove optional plugins from
BundledPlugins::LISTor add a metadata flag (e.g.,"optional": trueinplugin.json) and check it before auto-activation:$isOptional = $pluginMeta['metadata']['optional'] ?? false; if (!$isOptional) { // Insert with is_active = 1 and run onActivate }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Support/PluginManager.php` around lines 47 - 52, autoRegisterBundledPlugins currently iterates BundledPlugins::LIST and force-activates every plugin (inserting with is_active = 1 and calling onActivate()), which will auto-enable optional network-backed plugins like discogs; change the logic in autoRegisterBundledPlugins to read each plugin's plugin.json (or existing metadata loader) and check a metadata flag such as "optional" (e.g., $pluginMeta['metadata']['optional'] ?? false) and skip automatic activation for optional plugins (insert them with is_active = 0 or don't call onActivate()), only auto-activate those without the optional flag; reference BundledPlugins::LIST, autoRegisterBundledPlugins(), and onActivate() when locating the change.
♻️ Duplicate comments (16)
app/Controllers/LibriController.php (1)
679-680:⚠️ Potential issue | 🟠 MajorAvoid seeding
tipo_mediawith an empty default in create/update payloads.Line 679 and Line 1220 force
tipo_mediainto payloads even when the request did not provide it, which can silently override inferred/scraped media type handling downstream.♻️ Suggested fix
- 'tipo_media' => '',foreach ($fields as $k => $v) { if (array_key_exists($k, $data)) $fields[$k] = $data[$k]; } + if (!array_key_exists('tipo_media', $data) || trim((string) $data['tipo_media']) === '') { + unset($fields['tipo_media']); + }Also applies to: 1220-1221
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Controllers/LibriController.php` around lines 679 - 680, The payload builder in LibriController is forcibly setting 'tipo_media' => '' into create/update payloads which can override inferred media types; update the payload construction in the methods that build these arrays (where 'tipo_media' is currently assigned) to only include 'tipo_media' when the incoming request explicitly provides it (e.g., check $request->has('tipo_media') or isset on the input) or remove the default assignment entirely so the key is omitted when empty, ensuring downstream logic that infers/scrapes media type is not silently overridden.app/Views/frontend/catalog-grid.php (1)
41-44:⚠️ Potential issue | 🟡 MinorAdd an explicit accessible name for the media badge.
The badge is still icon-only and
titleis not a reliable accessible label. Addaria-labelon the badge and mark the icon decorative.♿ Suggested fix
- <span class="book-media-badge" title="<?= htmlspecialchars(\App\Support\MediaLabels::tipoMediaDisplayName($book['tipo_media']), ENT_QUOTES, 'UTF-8') ?>"> - <i class="fas <?= htmlspecialchars(\App\Support\MediaLabels::icon($book['tipo_media']), ENT_QUOTES, 'UTF-8') ?>"></i> + <span + class="book-media-badge" + title="<?= htmlspecialchars(\App\Support\MediaLabels::tipoMediaDisplayName($book['tipo_media']), ENT_QUOTES, 'UTF-8') ?>" + aria-label="<?= htmlspecialchars(\App\Support\MediaLabels::tipoMediaDisplayName($book['tipo_media']), ENT_QUOTES, 'UTF-8') ?>"> + <i class="fas <?= htmlspecialchars(\App\Support\MediaLabels::icon($book['tipo_media']), ENT_QUOTES, 'UTF-8') ?>" aria-hidden="true"></i> </span>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Views/frontend/catalog-grid.php` around lines 41 - 44, The media badge is currently icon-only and uses title (unreliable for accessibility); update the span.book-media-badge to include an explicit aria-label using htmlspecialchars(\App\Support\MediaLabels::tipoMediaDisplayName($book['tipo_media']), ENT_QUOTES, 'UTF-8') and keep the existing title if desired, and mark the inner <i> element decorative by adding aria-hidden="true" (or role="presentation") so screen readers read only the span label; locate the badge markup in catalog-grid.php using the span with class "book-media-badge" and the helper calls MediaLabels::tipoMediaDisplayName(...) and MediaLabels::icon(...) to apply these attributes.app/Views/frontend/catalog.php (1)
1302-1326:⚠️ Potential issue | 🟡 MinorKeep the
tipo_mediaUI in sync after AJAX refreshes.This still has the same client-side gap:
updateFilter('tipo_media', ...)never reapplies.activein the sidebar, andupdateActiveFiltersDisplay()still has no value→label map, so the chip falls back to raw enum keys. Mirror the availability sync path for media types and expose the labels to JS.Also applies to: 1451-1490, 1539-1556, 1603-1667
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Views/frontend/catalog.php` around lines 1302 - 1326, The tipo_media UI isn't kept in sync after AJAX updates because the client never gets the media-type labels or reapplies the active class; update the server output (from MediaLabels::allTypes) to expose a JS map of tipo_media value→label (and icon) alongside the existing availability data, then modify the client functions updateFilter('tipo_media', ...) and updateActiveFiltersDisplay() to use that map: when updateFilter is called for tipo_media ensure it toggles the .filter-option.active class for the matching .filter-option element (by matching its data/value) and updateActiveFiltersDisplay should translate enum keys into the exposed labels instead of showing raw keys; reference MediaLabels::allTypes, tipo_media, .filter-option, updateFilter, and updateActiveFiltersDisplay when making these changes.app/Support/PluginManager.php (1)
356-364:⚠️ Potential issue | 🟠 MajorCatch instantiation failures inside
getPluginInstance().
instantiatePlugin()can throw for missing files, wrapper/class resolution, or constructor/setup errors. Letting that escape from a nullable helper turns a broken plugin into a 500 instead ofnullfor the caller.As per coding guidelines, `Use \Throwable not \Exception for exception handling (strict_types TypeError extends \Error)`.🛠️ Minimal fix
public function getPluginInstance(int $pluginId): ?object { $plugin = $this->getPlugin($pluginId); if ($plugin === null) { return null; } - return $this->instantiatePlugin($plugin); + try { + return $this->instantiatePlugin($plugin); + } catch (\Throwable $e) { + SecureLogger::warning("[PluginManager] Failed to instantiate plugin {$plugin['name']}", [ + 'plugin_id' => $pluginId, + 'error' => $e->getMessage(), + ]); + return null; + } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Support/PluginManager.php` around lines 356 - 364, getPluginInstance currently lets instantiatePlugin exceptions bubble up, causing a 500; wrap the call to instantiatePlugin($plugin) in a try-catch that catches \Throwable (not \Exception) and returns null on failure so callers get null instead of an exception; locate getPluginInstance and modify the return path to catch any \Throwable from instantiatePlugin, optionally logging the error via the existing logger if available, then return null.app/Views/libri/partials/book_form.php (1)
424-428:⚠️ Potential issue | 🟡 MinorNormalize
tipo_mediabefore rendering or applying scraper results.This select only accepts
MediaLabels::allTypes()keys, but both$book['tipo_media']anddata.tipo_mediaare used raw. If the payload carries labels likeCD,LP, orvinyl, the control no longer reflects the stored/imported media type and it becomes easy to overwrite it on save. Whitelist/map to the canonical keys first and fall back to a safe default when there is no match.Also applies to: 3615-3620
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Views/libri/partials/book_form.php` around lines 424 - 428, Normalize and whitelist the media type before rendering and before applying scraper/imported values: map incoming values (from $book['tipo_media'] and data.tipo_media) to the canonical keys returned by MediaLabels::allTypes(), using a lookup that converts common labels like "CD", "LP", "vinyl" to their canonical key, and fall back to 'libro' when no match is found; update the select rendering (the block using MediaLabels::allTypes() and the option selected check) to use the normalized key and ensure any code that assigns data.tipo_media uses the same normalization function so the saved value always matches the allowed keys.storage/plugins/discogs/README.md (1)
15-18:⚠️ Potential issue | 🟡 MinorDocument the Discogs title fallback before MusicBrainz.
The flow here jumps from barcode lookup straight to MusicBrainz, so the README no longer matches the PR's barcode → title fallback behavior.
📝 Suggested text
1. Ricerca per barcode (EAN/UPC) su Discogs -- `GET /database/search?barcode={ean}&type=release` 2. Recupero dettagli completi della release Discogs -- `GET /releases/{id}` -3. **Fallback MusicBrainz** -- se Discogs non trova risultati, cerca su MusicBrainz per barcode -4. **Arricchimento Deezer** -- se manca la copertina o il genere, cerca su Deezer per titolo+artista +3. **Fallback Discogs per titolo/artista** -- se il barcode non restituisce risultati, prova `GET /database/search?q={title}+{artist}&type=release` +4. **Fallback MusicBrainz** -- se anche la ricerca Discogs per titolo non trova risultati, cerca su MusicBrainz per barcode +5. **Arricchimento Deezer** -- se manca la copertina o il genere, cerca su Deezer per titolo+artista🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@storage/plugins/discogs/README.md` around lines 15 - 18, Update the README's barcode resolution flow to reflect the actual PR behavior by inserting the Discogs title-based fallback step before the MusicBrainz fallback: keep the existing "Ricerca per barcode" and "Recupero dettagli completi della release Discogs" entries, then add a new bullet describing "Fallback titolo su Discogs (ricerca per titolo+artista quando il barcode non restituisce release)" before the existing "Fallback MusicBrainz" line, and ensure the Deezer enrichment line remains last so the sequence reads barcode → Discogs details → Discogs title fallback → MusicBrainz fallback → Deezer enrichment.app/Support/Updater.php (2)
2296-2325:⚠️ Potential issue | 🟡 MinorUnlink symlinks before treating them as files/directories.
removeDirectoryTree()still assumes every entry can be removed viaunlink()orrmdir(). A symlink-to-directory can hit theisDir()/rmdir()branch on Unix and make cleanup fail, which is especially risky because this helper is used in the swap recovery path.🧹 Suggested change
- if (!file_exists($path)) { + if (!file_exists($path) && !is_link($path)) { return; } + if (is_link($path)) { + if (!unlink($path)) { + throw new Exception(sprintf(__('Impossibile rimuovere link simbolico: %s'), $path)); + } + return; + } if (!is_dir($path)) { throw new Exception(sprintf(__('Percorso plugin non valido: %s'), $path)); } @@ foreach ($iterator as $item) { + if ($item->isLink()) { + if (!unlink($item->getPathname())) { + throw new Exception(sprintf(__('Impossibile rimuovere link simbolico: %s'), $item->getPathname())); + } + continue; + } if ($item->isDir()) { if (!rmdir($item->getPathname())) { throw new Exception(sprintf(__('Impossibile rimuovere directory: %s'), $item->getPathname())); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Support/Updater.php` around lines 2296 - 2325, removeDirectoryTree currently treats symlinks as normal files/directories which can cause rmdir failures for symlinked directories; update the loop in removeDirectoryTree to check $item->isLink() first and call unlink($item->getPathname()) for symlinks (throw the same Exception on failure), only then branch into $item->isDir() => rmdir(...) and else => unlink(...), so symlink-to-directory is removed via unlink instead of rmdir while preserving the existing error messages and behavior.
2246-2263:⚠️ Potential issue | 🟠 MajorMake backup cleanup non-fatal after a successful swap.
Once Line 2254 has activated the staged plugin, an exception from Line 2262 aborts the whole update even though
restoreAppFiles()never rolls backstorage/plugins. Cleanup failure here should be logged, not rethrown.♻️ Suggested change
- if (is_dir($backupPath)) { - $this->removeDirectoryTree($backupPath); - } + if (is_dir($backupPath)) { + try { + $this->removeDirectoryTree($backupPath); + } catch (\Throwable $cleanupError) { + $this->debugLog('WARNING', 'Impossibile rimuovere il backup del plugin aggiornato', [ + 'plugin' => $pluginSlug, + 'backup' => $backupPath, + 'error' => $cleanupError->getMessage(), + ]); + } + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Support/Updater.php` around lines 2246 - 2263, The backup cleanup after activating the staged plugin should not be fatal: wrap the final backup removal (the call to $this->removeDirectoryTree($backupPath) in the activation path after rename($stagingPath, $targetPluginPath)) in a try/catch and, on failure, log the error instead of rethrowing so the update proceeds; use the class's existing logging facility (e.g. $this->logger->error or the same logger used elsewhere in Updater.php) and include context (plugin slug and $e->getMessage()) while ensuring no exception from removeDirectoryTree aborts the successful swap or interferes with restoreAppFiles() or storage/plugins state.tests/multisource-scraping.spec.js (1)
114-118:⚠️ Potential issue | 🟠 MajorDelete the created
librirows in teardown.The suite captures every created id but
afterAll()only closes the browser context. Re-running this spec against a shared E2E database will keep accumulatingRUN_TAGrows and eventually contaminate the list/search assertions.🧹 Suggested change
test.afterAll(async () => { + const ids = [manualBookId, importedBookId, manualDiscId, importedDisc1Id, importedDisc2Id] + .map(Number) + .filter((id) => id > 0); + const filters = [`titolo LIKE '${sqlEscape(RUN_TAG)}%'`]; + if (ids.length > 0) { + filters.unshift(`id IN (${ids.join(',')})`); + } + try { + dbQuery(`DELETE FROM libri WHERE ${filters.join(' OR ')}`); + } catch {} await context?.close(); });Also applies to: 131-133
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/multisource-scraping.spec.js` around lines 114 - 118, The test suite currently captures created record IDs (manualBookId, importedBookId, manualDiscId, importedDisc1Id, importedDisc2Id and the similar vars at the other block) but never deletes those rows in afterAll; update the afterAll teardown to delete the created libri rows (or rows tagged with RUN_TAG) by using the captured IDs to remove the specific records (or a RUN_TAG-scoped delete) so re-running the spec against a shared E2E DB doesn't accumulate data; locate the afterAll function and add cleanup logic that issues deletes for the recorded IDs (or a single delete filtered by RUN_TAG) and ensure it runs after closing the browser context.storage/plugins/discogs/DiscogsPlugin.php (3)
369-374:⚠️ Potential issue | 🟠 MajorHonor explicit
tipo_mediabefore inferring a music source.
inferTipoMedia('cd_audio')still returnsdiscoeven when$data['tipo_media']isaudiolibro, so audiobook-on-CD payloads can be pushed through Discogs/Deezer enrichment and pick up album art. Resolve the media type with the explicit value included, then test that resolved value.💡 Minimal fix
- $resolvedType = \App\Support\MediaLabels::inferTipoMedia($data['format'] ?? $data['formato'] ?? ''); - $isMusicSource = ($data['tipo_media'] ?? '') === 'disco' - || $resolvedType === 'disco' + $resolvedType = \App\Support\MediaLabels::resolveTipoMedia( + $data['format'] ?? $data['formato'] ?? null, + $data['tipo_media'] ?? null + ); + $isMusicSource = $resolvedType === 'disco' || ($data['source'] ?? '') === 'discogs' || ($data['source'] ?? '') === 'musicbrainz';🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@storage/plugins/discogs/DiscogsPlugin.php` around lines 369 - 374, The code currently calls \App\Support\MediaLabels::inferTipoMedia(...) and then compares $data['tipo_media'] separately, which lets inferred values override an explicit tipo_media; change the resolution logic so the explicit $data['tipo_media'] (if present and non-empty) is used first and only fall back to inferTipoMedia(...) otherwise (update the $resolvedType assignment), then keep the $isMusicSource check using that $resolvedType along with the existing source checks; reference: inferTipoMedia, $resolvedType, $isMusicSource, and $data['tipo_media'].
937-942:⚠️ Potential issue | 🟠 MajorPass the real payload source into
BookDataMerger.
mapMusicBrainzToPinakes()already returnssource => 'musicbrainz', but this call always tells the merger the new data came from Discogs. That mislabels fallback hits and can skew_primary_sourceand source-weighted selection.💡 Minimal fix
private function mergeBookData(?array $existing, ?array $new): ?array { // Use BookDataMerger if available if (class_exists('\\App\\Support\\BookDataMerger')) { - return \App\Support\BookDataMerger::merge($existing, $new, 'discogs'); + $mergeSource = is_string($new['source'] ?? null) && $new['source'] !== '' + ? $new['source'] + : (is_string($existing['source'] ?? null) && $existing['source'] !== '' ? $existing['source'] : 'discogs'); + return \App\Support\BookDataMerger::merge($existing, $new, $mergeSource); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@storage/plugins/discogs/DiscogsPlugin.php` around lines 937 - 942, The mergeBookData method currently calls \App\Support\BookDataMerger::merge($existing, $new, 'discogs') which hardcodes the payload source; change it to derive the source from the incoming payload (e.g. $new['source'] if set) and pass that value into BookDataMerger::merge instead of the literal 'discogs' so fallback hits retain their real source; update mergeBookData (and the \App\Support\BookDataMerger::merge call site) to default to 'discogs' only when the new payload has no source field.
310-316:⚠️ Potential issue | 🟠 MajorNormalize structured
authorsentries before building the fallback query.If
authorsarrives as[['name' => '...']],(string) ($currentResult['authors'][0] ?? '')becomes"Array", so the Discogs fallback sends an invalidartist=filter and misses valid releases.💡 Minimal fix
$artist = trim((string) ($currentResult['author'] ?? '')); if ($artist === '' && !empty($currentResult['authors'])) { if (is_array($currentResult['authors'])) { - $artist = trim((string) ($currentResult['authors'][0] ?? '')); + $firstAuthor = $currentResult['authors'][0] ?? ''; + if (is_array($firstAuthor)) { + $artist = trim((string) ($firstAuthor['name'] ?? '')); + } else { + $artist = trim((string) $firstAuthor); + } } else { $artist = trim((string) $currentResult['authors']); } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@storage/plugins/discogs/DiscogsPlugin.php` around lines 310 - 316, The code that sets $artist from $currentResult['authors'] can cast an array to the string "Array" when entries are structured (e.g. ['name' => '...']); update the fallback logic in DiscogsPlugin.php where $artist and $currentResult['authors'] are handled to normalize authors before casting—if $currentResult['authors'] is an array and its first element is an array with a 'name' key, use that 'name' value (or if it's a list of such arrays, extract the first non-empty 'name'), otherwise fall back to the existing string handling; ensure you still apply trim((string) ...) to the final value so the Discogs artist= filter receives a proper string.tests/discogs-import.spec.js (2)
115-120:⚠️ Potential issue | 🟠 MajorReplace the imported public barcode before submitting the form.
This flow imports a real Discogs barcode and then persists it unchanged into
ean/isbn13, which are unique fields. Reruns can collide with existing/shared data and turn the save step into a flaky uniqueness failure.🧪 Minimal fix
const isbn13Value = await page.locator('input[name="isbn13"]').inputValue(); expect(eanValue === TEST_BARCODE || isbn13Value === TEST_BARCODE || eanValue.includes('720642442524') || isbn13Value.includes('720642442524')).toBe(true); + + const uniqueBarcode = `${Date.now()}${Math.floor(Math.random() * 10)}`.slice(-13); + const eanField = page.locator('input[name="ean"]'); + const isbn13Field = page.locator('input[name="isbn13"]'); + if (await eanField.isVisible().catch(() => false)) { + await eanField.fill(uniqueBarcode); + } + if (await isbn13Field.isVisible().catch(() => false)) { + await isbn13Field.fill(uniqueBarcode); + }Also applies to: 123-149
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/discogs-import.spec.js` around lines 115 - 120, The test currently persists a real public Discogs barcode into the form (checked via input[name="ean"] and input[name="isbn13"]) which can cause uniqueness collisions on re-runs; before submitting the form in the discogs-import.spec.js flow, detect if the imported value equals the public barcode (e.g., TEST_BARCODE or '720642442524') and programmatically replace it with a unique value (for example build one using a timestamp or random suffix) by updating the same locators ('input[name="ean"]' and 'input[name="isbn13"]') so the save step uses a non-colliding barcode. Ensure both fields are set consistently and then proceed to submit.
51-57:⚠️ Potential issue | 🟠 MajorAvoid raw
DELETE FROM libriin teardown.Saving through the admin form can create dependent
copierows, so this hard delete can fail on FK constraints, and the emptycatch {}makes the leak invisible. Use the normal delete/soft-delete path, or remove dependents first and surface cleanup failures.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/discogs-import.spec.js` around lines 51 - 57, The teardown uses a raw hard DELETE via dbExec when createdId is set, which can fail silently due to FK constraints on dependent copie rows and the empty catch hides failures; update test.afterAll to call the application's normal delete/soft-delete path (e.g., invoke the same controller/service method used for admin deletes) or explicitly remove dependent copie rows first before deleting libri, and remove the empty catch so any cleanup errors surface (look for createdId, dbExec, and test.afterAll to modify).tests/discogs-plugin.spec.js (1)
204-208:⚠️ Potential issue | 🟠 MajorThis scrape smoke test can still pass without proving anything.
After activation, a missing import button is a failure, not a skip. And in the empty-title branch the console listener is attached after the import attempt and never asserted, so JS errors or a broken scrape path still go green.
🧪 Minimal fix
+ const logs = []; + page.on('console', msg => { + if (msg.type() === 'error') logs.push(msg.text()); + }); + const importBtn = page.locator('#btnImportIsbn'); - if (!await importBtn.isVisible({ timeout: 3000 }).catch(() => false)) { - test.skip(true, 'Import button not visible'); - return; - } + await expect(importBtn, 'Import button not visible after Discogs activation').toBeVisible({ timeout: 3000 }); @@ - } else { - // Scraping might have failed (rate limit, network) — that's OK for CI - // Just verify no JS errors occurred - const logs = []; - page.on('console', msg => { if (msg.type() === 'error') logs.push(msg.text()); }); - } + } else { + expect(logs, 'Scrape returned no data and emitted console errors').toEqual([]); + }Also applies to: 239-243
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/discogs-plugin.spec.js` around lines 204 - 208, The test currently skips when the import button is missing and attaches the page console listener after triggering import, which hides real failures; change the behavior so that the absence of the import button (identified via importBtn = page.locator('#btnImportIsbn') and importBtn.isVisible(...)) throws or fails the test instead of calling test.skip, move the console listener attachment (the page.on('console', ...) handler used in the empty-title branch) to before any import attempt, and add explicit assertions on captured console events/errors after the import action to ensure JS errors or scrape failures are asserted; apply the same fixes to the duplicate block referenced around lines 239-243 (where the same importBtn/console logic is duplicated).app/Views/frontend/book-detail.php (1)
2239-2240:⚠️ Potential issue | 🟡 MinorTreat blank
autorias missing here.
??only falls back onnull, so''still renders an empty author line instead of the music-aware unknown-author label.💡 Minimal fix
- <?= htmlspecialchars($related['autori'] ?? __($relatedIsMusic ? 'Artista sconosciuto' : 'Autore sconosciuto'), ENT_QUOTES, 'UTF-8') ?> + <?php $relatedAuthorsLabel = trim((string) ($related['autori'] ?? '')); ?> + <?= htmlspecialchars( + $relatedAuthorsLabel !== '' ? $relatedAuthorsLabel : __($relatedIsMusic ? 'Artista sconosciuto' : 'Autore sconosciuto'), + ENT_QUOTES, + 'UTF-8' + ) ?>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Views/frontend/book-detail.php` around lines 2239 - 2240, The template currently only falls back when $related['autori'] is null, so blank strings still render an empty author; update the logic around $related['autori'] (used inside the related-book-author paragraph and passed to htmlspecialchars) to treat empty or whitespace-only strings as missing by checking trim($related['autori']) (or casting to string and testing length) and, when empty, use the music-aware label determined by $relatedIsMusic (__( 'Artista sconosciuto' ) vs __( 'Autore sconosciuto' )), otherwise pass the actual author through htmlspecialchars as before.
🧹 Nitpick comments (2)
locale/en_US.json (2)
2838-2838: Prefer a broader English label forCartaceo.Line 2838 maps
CartaceotoPaperback, which is narrower than the likely media-type meaning (print/physical). This can mislabel non-paperback printed items.Suggested wording update
- "Cartaceo": "Paperback", + "Cartaceo": "Print",🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@locale/en_US.json` at line 2838, The translation key "Cartaceo" in locale/en_US.json is mapped too narrowly to "Paperback"; change the value to a broader English label that covers all printed/physical media (e.g., "Print (Physical)", "Physical copy", or "Printed material") so non-paperback printed items aren't misclassified—update the value for the "Cartaceo" key accordingly.
4194-4194:Disco→Recordmay be too vinyl-specific.Line 4194 can read as “vinyl record” in English. If this key is the generic media type bucket,
Discis safer and broader.Suggested wording update
- "Disco": "Record", + "Disco": "Disc",🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@locale/en_US.json` at line 4194, The i18n key "Disco" with value "Record" is vinyl-specific; rename the JSON key "Disco" to "Disc" (keep or adjust the value as needed) and update all code references that use the "Disco" translation key (e.g., any calls to t('Disco') or i18n lookup) to use "Disc" instead so runtime lookups remain correct; ensure the change is applied in the same locale file and mirrored in other locale files if they exist.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/Controllers/CsvImportController.php`:
- Around line 794-797: The code in CsvImportController that sets 'formato' =>
!empty($row['formato']) ? trim($row['formato']) : 'cartaceo' incorrectly
defaults missing formato to 'cartaceo' even when tipo_media is present; change
this so missing formato is set to null (not 'cartaceo'), preserve the existing
DB value when performing updates, and only derive a sensible fallback for
formato during inserts (e.g., derive from tipo_media or default to 'cartaceo' at
insert time). Update the handling where tipo_media is normalized via
\App\Support\MediaLabels::normalizeTipoMedia to not rely on a forced formato
fallback, and apply the same fix to the other similar occurrences noted (the
other CsvImportController blocks that set 'formato' with a 'cartaceo' fallback).
In `@app/Controllers/FrontendController.php`:
- Around line 904-908: The current check in FrontendController (around the block
using hasLibriColumn and filters['tipo_media']) silently skips applying the
tipo_media filter when the libri table lacks that column; update the controller
to detect this case and communicate it back to the view/API instead of silently
ignoring it: when filters['tipo_media'] is present but hasLibriColumn($db,
'tipo_media') returns false, set a flag or clear the incoming
filters['tipo_media'] value (e.g., add a response field like
'unsupported_filters' or remove the key from the filters array) so the view can
hide/disable the tipo_media UI or show a warning, and apply the same behavior to
the similar block at the other location (the section referenced at 917-927) so
both places consistently surface unsupported filters rather than becoming
no-ops.
In `@app/Controllers/LibraryThingImportController.php`:
- Around line 1348-1351: The code unconditionally defaults missing formato to
'cartaceo' even when tipo_media indicates a non-book item; change the logic so
when $hasTipoMedia is true you do not coerce formato to 'cartaceo' — instead
preserve an empty/null formato (e.g. use $data['formato'] ?? null or an empty
string) and only apply the 'cartaceo' default when $hasTipoMedia is false;
update the same pattern wherever formato is set (the blocks using $params[] with
$hasTipoMedia and MediaLabels::normalizeTipoMedia) so tipo_media='disco' (or
other non-book) cannot be paired with formato='cartaceo'.
In `@app/Controllers/ScrapeController.php`:
- Around line 148-151: The Discogs plugin needs to register the
scrape.isbn.validate hook so barcode-only EAN/UPC values bypass the ISBN-only
gate; update the Discogs plugin to add a scrape.isbn.validate handler that
returns true for valid EAN/UPC formats (e.g., 12/13-digit numeric barcodes) so
requests pass the validation in ScrapeController (which calls ensureTipoMedia
and normalizeIsbnFields and currently rejects non-ISBN barcodes before
scrape.fetch.custom/fetchFromDiscogs runs); implement the handler in the
plugin's bootstrap/registration code and ensure it only returns true for
legitimate EAN/UPC patterns to avoid weakening ISBN validation globally.
In `@app/Views/frontend/book-detail.php`:
- Around line 1758-1761: Replace the hardcoded "ISBN-13" meta-label with the
dynamic label generator so music rows that carry a barcode in isbn13 show the
correct label: call MediaLabels::label('isbn13', $book['isbn13']) (while still
escaping the value for output) instead of printing the literal "ISBN-13"; do the
same change for the repeated block that appears later (the similar block at the
other occurrence). Ensure you still render the escaped value via
htmlspecialchars($book['isbn13'], ENT_QUOTES, 'UTF-8') but use
MediaLabels::label('isbn13', $book['isbn13']) for the meta-label so music
identifiers map to "Barcode".
In `@app/Views/libri/scheda_libro.php`:
- Around line 603-606: The tracklist formatting collapses lines because
strip_tags($libro['descrizione']) removes <br> and similar separators; before
calling strip_tags() in the isMusic branch, normalize HTML line separators into
newline characters (e.g. replace <br>, <br/>, <br /> and block tags like </p>
with "\n" using a case-insensitive str_ireplace or preg_replace) and then call
strip_tags() so App\Support\MediaLabels::formatTracklist receives a
newline-separated string and can parse individual tracks correctly.
In `@installer/database/migrations/migrate_0.5.4.sql`:
- Around line 4-10: The ALTER should not assume the existence of libri.formato;
modify the migration logic that builds `@sql` (which currently checks `@col_exists`
for tipo_media) to also check for the existence of the formato column and choose
between two ALTER variants: one that appends "AFTER formato" if formato exists
and another that adds tipo_media without an AFTER clause if formato is absent,
ensuring the table 'libri' and column 'tipo_media' are still created even when
formato was never added.
In `@storage/plugins/discogs/views/settings.php`:
- Around line 112-116: The token visibility toggle button has no accessible
name; update the button element used with
onclick="togglePasswordVisibility('api_token')" (the button containing <i
id="api_token_icon">) to include an aria-label (e.g., "Show API token" / "Hide
API token") and modify the togglePasswordVisibility function to update that
aria-label each time it flips state so the label always reflects the current
action and stays in sync with the icon state.
In `@tests/pr100-media-types.spec.js`:
- Line 17: The test currently reuses fixed seeded identifiers (the RUN_ID
constant and hardcoded ean/isbn13 values) across runs which can collide in a
shared E2E DB; change the seed generation in tests/pr100-media-types.spec.js to
create unique per-run identifiers (e.g., append the RUN_ID or a UUID/random
suffix to the ean/isbn13 strings when inserting fixtures) and update the
teardown/cleanup queries to target those exact generated identifiers (reference
RUN_ID and the ean/isbn13 variables in the insert and delete logic so the
teardown deletes only the rows created by this run).
---
Outside diff comments:
In `@app/Support/PluginManager.php`:
- Around line 47-52: autoRegisterBundledPlugins currently iterates
BundledPlugins::LIST and force-activates every plugin (inserting with is_active
= 1 and calling onActivate()), which will auto-enable optional network-backed
plugins like discogs; change the logic in autoRegisterBundledPlugins to read
each plugin's plugin.json (or existing metadata loader) and check a metadata
flag such as "optional" (e.g., $pluginMeta['metadata']['optional'] ?? false) and
skip automatic activation for optional plugins (insert them with is_active = 0
or don't call onActivate()), only auto-activate those without the optional flag;
reference BundledPlugins::LIST, autoRegisterBundledPlugins(), and onActivate()
when locating the change.
---
Duplicate comments:
In `@app/Controllers/LibriController.php`:
- Around line 679-680: The payload builder in LibriController is forcibly
setting 'tipo_media' => '' into create/update payloads which can override
inferred media types; update the payload construction in the methods that build
these arrays (where 'tipo_media' is currently assigned) to only include
'tipo_media' when the incoming request explicitly provides it (e.g., check
$request->has('tipo_media') or isset on the input) or remove the default
assignment entirely so the key is omitted when empty, ensuring downstream logic
that infers/scrapes media type is not silently overridden.
In `@app/Support/PluginManager.php`:
- Around line 356-364: getPluginInstance currently lets instantiatePlugin
exceptions bubble up, causing a 500; wrap the call to instantiatePlugin($plugin)
in a try-catch that catches \Throwable (not \Exception) and returns null on
failure so callers get null instead of an exception; locate getPluginInstance
and modify the return path to catch any \Throwable from instantiatePlugin,
optionally logging the error via the existing logger if available, then return
null.
In `@app/Support/Updater.php`:
- Around line 2296-2325: removeDirectoryTree currently treats symlinks as normal
files/directories which can cause rmdir failures for symlinked directories;
update the loop in removeDirectoryTree to check $item->isLink() first and call
unlink($item->getPathname()) for symlinks (throw the same Exception on failure),
only then branch into $item->isDir() => rmdir(...) and else => unlink(...), so
symlink-to-directory is removed via unlink instead of rmdir while preserving the
existing error messages and behavior.
- Around line 2246-2263: The backup cleanup after activating the staged plugin
should not be fatal: wrap the final backup removal (the call to
$this->removeDirectoryTree($backupPath) in the activation path after
rename($stagingPath, $targetPluginPath)) in a try/catch and, on failure, log the
error instead of rethrowing so the update proceeds; use the class's existing
logging facility (e.g. $this->logger->error or the same logger used elsewhere in
Updater.php) and include context (plugin slug and $e->getMessage()) while
ensuring no exception from removeDirectoryTree aborts the successful swap or
interferes with restoreAppFiles() or storage/plugins state.
In `@app/Views/frontend/book-detail.php`:
- Around line 2239-2240: The template currently only falls back when
$related['autori'] is null, so blank strings still render an empty author;
update the logic around $related['autori'] (used inside the related-book-author
paragraph and passed to htmlspecialchars) to treat empty or whitespace-only
strings as missing by checking trim($related['autori']) (or casting to string
and testing length) and, when empty, use the music-aware label determined by
$relatedIsMusic (__( 'Artista sconosciuto' ) vs __( 'Autore sconosciuto' )),
otherwise pass the actual author through htmlspecialchars as before.
In `@app/Views/frontend/catalog-grid.php`:
- Around line 41-44: The media badge is currently icon-only and uses title
(unreliable for accessibility); update the span.book-media-badge to include an
explicit aria-label using
htmlspecialchars(\App\Support\MediaLabels::tipoMediaDisplayName($book['tipo_media']),
ENT_QUOTES, 'UTF-8') and keep the existing title if desired, and mark the inner
<i> element decorative by adding aria-hidden="true" (or role="presentation") so
screen readers read only the span label; locate the badge markup in
catalog-grid.php using the span with class "book-media-badge" and the helper
calls MediaLabels::tipoMediaDisplayName(...) and MediaLabels::icon(...) to apply
these attributes.
In `@app/Views/frontend/catalog.php`:
- Around line 1302-1326: The tipo_media UI isn't kept in sync after AJAX updates
because the client never gets the media-type labels or reapplies the active
class; update the server output (from MediaLabels::allTypes) to expose a JS map
of tipo_media value→label (and icon) alongside the existing availability data,
then modify the client functions updateFilter('tipo_media', ...) and
updateActiveFiltersDisplay() to use that map: when updateFilter is called for
tipo_media ensure it toggles the .filter-option.active class for the matching
.filter-option element (by matching its data/value) and
updateActiveFiltersDisplay should translate enum keys into the exposed labels
instead of showing raw keys; reference MediaLabels::allTypes, tipo_media,
.filter-option, updateFilter, and updateActiveFiltersDisplay when making these
changes.
In `@app/Views/libri/partials/book_form.php`:
- Around line 424-428: Normalize and whitelist the media type before rendering
and before applying scraper/imported values: map incoming values (from
$book['tipo_media'] and data.tipo_media) to the canonical keys returned by
MediaLabels::allTypes(), using a lookup that converts common labels like "CD",
"LP", "vinyl" to their canonical key, and fall back to 'libro' when no match is
found; update the select rendering (the block using MediaLabels::allTypes() and
the option selected check) to use the normalized key and ensure any code that
assigns data.tipo_media uses the same normalization function so the saved value
always matches the allowed keys.
In `@storage/plugins/discogs/DiscogsPlugin.php`:
- Around line 369-374: The code currently calls
\App\Support\MediaLabels::inferTipoMedia(...) and then compares
$data['tipo_media'] separately, which lets inferred values override an explicit
tipo_media; change the resolution logic so the explicit $data['tipo_media'] (if
present and non-empty) is used first and only fall back to inferTipoMedia(...)
otherwise (update the $resolvedType assignment), then keep the $isMusicSource
check using that $resolvedType along with the existing source checks; reference:
inferTipoMedia, $resolvedType, $isMusicSource, and $data['tipo_media'].
- Around line 937-942: The mergeBookData method currently calls
\App\Support\BookDataMerger::merge($existing, $new, 'discogs') which hardcodes
the payload source; change it to derive the source from the incoming payload
(e.g. $new['source'] if set) and pass that value into BookDataMerger::merge
instead of the literal 'discogs' so fallback hits retain their real source;
update mergeBookData (and the \App\Support\BookDataMerger::merge call site) to
default to 'discogs' only when the new payload has no source field.
- Around line 310-316: The code that sets $artist from $currentResult['authors']
can cast an array to the string "Array" when entries are structured (e.g.
['name' => '...']); update the fallback logic in DiscogsPlugin.php where $artist
and $currentResult['authors'] are handled to normalize authors before casting—if
$currentResult['authors'] is an array and its first element is an array with a
'name' key, use that 'name' value (or if it's a list of such arrays, extract the
first non-empty 'name'), otherwise fall back to the existing string handling;
ensure you still apply trim((string) ...) to the final value so the Discogs
artist= filter receives a proper string.
In `@storage/plugins/discogs/README.md`:
- Around line 15-18: Update the README's barcode resolution flow to reflect the
actual PR behavior by inserting the Discogs title-based fallback step before the
MusicBrainz fallback: keep the existing "Ricerca per barcode" and "Recupero
dettagli completi della release Discogs" entries, then add a new bullet
describing "Fallback titolo su Discogs (ricerca per titolo+artista quando il
barcode non restituisce release)" before the existing "Fallback MusicBrainz"
line, and ensure the Deezer enrichment line remains last so the sequence reads
barcode → Discogs details → Discogs title fallback → MusicBrainz fallback →
Deezer enrichment.
In `@tests/discogs-import.spec.js`:
- Around line 115-120: The test currently persists a real public Discogs barcode
into the form (checked via input[name="ean"] and input[name="isbn13"]) which can
cause uniqueness collisions on re-runs; before submitting the form in the
discogs-import.spec.js flow, detect if the imported value equals the public
barcode (e.g., TEST_BARCODE or '720642442524') and programmatically replace it
with a unique value (for example build one using a timestamp or random suffix)
by updating the same locators ('input[name="ean"]' and 'input[name="isbn13"]')
so the save step uses a non-colliding barcode. Ensure both fields are set
consistently and then proceed to submit.
- Around line 51-57: The teardown uses a raw hard DELETE via dbExec when
createdId is set, which can fail silently due to FK constraints on dependent
copie rows and the empty catch hides failures; update test.afterAll to call the
application's normal delete/soft-delete path (e.g., invoke the same
controller/service method used for admin deletes) or explicitly remove dependent
copie rows first before deleting libri, and remove the empty catch so any
cleanup errors surface (look for createdId, dbExec, and test.afterAll to
modify).
In `@tests/discogs-plugin.spec.js`:
- Around line 204-208: The test currently skips when the import button is
missing and attaches the page console listener after triggering import, which
hides real failures; change the behavior so that the absence of the import
button (identified via importBtn = page.locator('#btnImportIsbn') and
importBtn.isVisible(...)) throws or fails the test instead of calling test.skip,
move the console listener attachment (the page.on('console', ...) handler used
in the empty-title branch) to before any import attempt, and add explicit
assertions on captured console events/errors after the import action to ensure
JS errors or scrape failures are asserted; apply the same fixes to the duplicate
block referenced around lines 239-243 (where the same importBtn/console logic is
duplicated).
In `@tests/multisource-scraping.spec.js`:
- Around line 114-118: The test suite currently captures created record IDs
(manualBookId, importedBookId, manualDiscId, importedDisc1Id, importedDisc2Id
and the similar vars at the other block) but never deletes those rows in
afterAll; update the afterAll teardown to delete the created libri rows (or rows
tagged with RUN_TAG) by using the captured IDs to remove the specific records
(or a RUN_TAG-scoped delete) so re-running the spec against a shared E2E DB
doesn't accumulate data; locate the afterAll function and add cleanup logic that
issues deletes for the recorded IDs (or a single delete filtered by RUN_TAG) and
ensure it runs after closing the browser context.
---
Nitpick comments:
In `@locale/en_US.json`:
- Line 2838: The translation key "Cartaceo" in locale/en_US.json is mapped too
narrowly to "Paperback"; change the value to a broader English label that covers
all printed/physical media (e.g., "Print (Physical)", "Physical copy", or
"Printed material") so non-paperback printed items aren't misclassified—update
the value for the "Cartaceo" key accordingly.
- Line 4194: The i18n key "Disco" with value "Record" is vinyl-specific; rename
the JSON key "Disco" to "Disc" (keep or adjust the value as needed) and update
all code references that use the "Disco" translation key (e.g., any calls to
t('Disco') or i18n lookup) to use "Disc" instead so runtime lookups remain
correct; ensure the change is applied in the same locale file and mirrored in
other locale files if they exist.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: ca4cb812-3685-4e4b-9ef0-58c2a12fb6b9
📒 Files selected for processing (44)
.gitignoreapp/Controllers/CsvImportController.phpapp/Controllers/FrontendController.phpapp/Controllers/LibraryThingImportController.phpapp/Controllers/LibriApiController.phpapp/Controllers/LibriController.phpapp/Controllers/PluginController.phpapp/Controllers/PublicApiController.phpapp/Controllers/ScrapeController.phpapp/Models/BookRepository.phpapp/Routes/web.phpapp/Support/BundledPlugins.phpapp/Support/MediaLabels.phpapp/Support/PluginManager.phpapp/Support/RouteTranslator.phpapp/Support/Updater.phpapp/Views/admin/plugins.phpapp/Views/frontend/book-detail.phpapp/Views/frontend/catalog-grid.phpapp/Views/frontend/catalog.phpapp/Views/libri/index.phpapp/Views/libri/partials/book_form.phpapp/Views/libri/scheda_libro.phpinstaller/classes/Installer.phpinstaller/database/migrations/migrate_0.5.4.sqlinstaller/database/schema.sqllocale/de_DE.jsonlocale/en_US.jsonlocale/it_IT.jsonstorage/plugins/discogs/DiscogsPlugin.phpstorage/plugins/discogs/README.mdstorage/plugins/discogs/plugin.jsonstorage/plugins/discogs/views/settings.phpstorage/plugins/discogs/wrapper.phptests/discogs-advanced.spec.jstests/discogs-import.spec.jstests/discogs-plugin.spec.jstests/multisource-scraping.spec.jstests/playwright.config.jstests/pr100-media-types.spec.jsvendor/composer/autoload_classmap.phpvendor/composer/autoload_static.phpvendor/composer/installed.phpversion.json
| 'formato' => !empty($row['formato']) ? trim($row['formato']) : 'cartaceo', | ||
| 'tipo_media' => array_key_exists('tipo_media', $row) && trim((string) ($row['tipo_media'] ?? '')) !== '' | ||
| ? \App\Support\MediaLabels::normalizeTipoMedia(trim((string) $row['tipo_media'])) | ||
| : null, |
There was a problem hiding this comment.
Don’t default missing formato to cartaceo when tipo_media drives the import.
Rows that only provide tipo_media still parse with formato = 'cartaceo', and both write paths persist that fallback. Inserts then store a music item with the wrong format, while updates can silently reset an existing CD/LP/vinyl row back to cartaceo. Keep missing formato nullable, preserve the current DB value on updates, and only derive a fallback on insert.
Also applies to: 1253-1255, 1269-1270, 1355-1358, 1363-1369
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/Controllers/CsvImportController.php` around lines 794 - 797, The code in
CsvImportController that sets 'formato' => !empty($row['formato']) ?
trim($row['formato']) : 'cartaceo' incorrectly defaults missing formato to
'cartaceo' even when tipo_media is present; change this so missing formato is
set to null (not 'cartaceo'), preserve the existing DB value when performing
updates, and only derive a sensible fallback for formato during inserts (e.g.,
derive from tipo_media or default to 'cartaceo' at insert time). Update the
handling where tipo_media is normalized via
\App\Support\MediaLabels::normalizeTipoMedia to not rely on a forced formato
fallback, and apply the same fix to the other similar occurrences noted (the
other CsvImportController blocks that set 'formato' with a 'cartaceo' fallback).
| if (!empty($filters['tipo_media']) && $this->hasLibriColumn($db, 'tipo_media')) { | ||
| $conditions[] = "l.tipo_media = ?"; | ||
| $params[] = $filters['tipo_media']; | ||
| $types .= 's'; | ||
| } |
There was a problem hiding this comment.
Don't turn unsupported tipo_media into a silent no-op.
This guard avoids the SQL error, but pre-migration installs can now keep tipo_media active in the URL/UI while the query is unfiltered. Please surface this capability to the view/API and clear or hide the filter when hasLibriColumn() is false.
Also applies to: 917-927
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/Controllers/FrontendController.php` around lines 904 - 908, The current
check in FrontendController (around the block using hasLibriColumn and
filters['tipo_media']) silently skips applying the tipo_media filter when the
libri table lacks that column; update the controller to detect this case and
communicate it back to the view/API instead of silently ignoring it: when
filters['tipo_media'] is present but hasLibriColumn($db, 'tipo_media') returns
false, set a flag or clear the incoming filters['tipo_media'] value (e.g., add a
response field like 'unsupported_filters' or remove the key from the filters
array) so the view can hide/disable the tipo_media UI or show a warning, and
apply the same behavior to the similar block at the other location (the section
referenced at 917-927) so both places consistently surface unsupported filters
rather than becoming no-ops.
| $params[] = !empty($data['formato']) ? $data['formato'] : 'cartaceo'; | ||
| if ($hasTipoMedia) { | ||
| $params[] = \App\Support\MediaLabels::normalizeTipoMedia($data['tipo_media'] ?? null); | ||
| } |
There was a problem hiding this comment.
Don't coerce non-book imports back to cartaceo.
Lines 1348-1351, 1423-1426, 1536-1540, and 1615-1619 still default a missing formato to 'cartaceo' even when the new tipo_media flow resolved a non-book value. With the current parser, media strings like CD/LP/Vinyl can set tipo_media = disco while leaving formato empty, so inserts/updates persist inconsistent metadata (tipo_media = disco, formato = cartaceo).
Also applies to: 1423-1426, 1536-1540, 1615-1619
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/Controllers/LibraryThingImportController.php` around lines 1348 - 1351,
The code unconditionally defaults missing formato to 'cartaceo' even when
tipo_media indicates a non-book item; change the logic so when $hasTipoMedia is
true you do not coerce formato to 'cartaceo' — instead preserve an empty/null
formato (e.g. use $data['formato'] ?? null or an empty string) and only apply
the 'cartaceo' default when $hasTipoMedia is false; update the same pattern
wherever formato is set (the blocks using $params[] with $hasTipoMedia and
MediaLabels::normalizeTipoMedia) so tipo_media='disco' (or other non-book)
cannot be paired with formato='cartaceo'.
| $payload = $this->ensureTipoMedia($payload); | ||
|
|
||
| // Normalize ISBN fields (auto-calculate missing isbn10/isbn13) | ||
| $payload = $this->normalizeIsbnFields($payload, $cleanIsbn); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "Discogs hook registrations:"
rg -n -C2 "scrape\.(isbn\.validate|sources|fetch\.custom|data\.modify)" storage/plugins/discogs app/Controllers/ScrapeController.php
echo
echo "Barcode/EAN references in the Discogs plugin:"
rg -n -C2 "barcode|ean|upc" storage/plugins/discogs
echo
echo "Current validation gate in ScrapeController:"
rg -n -C4 "isValidIsbn|scrape\.isbn\.validate|scrape\.fetch\.custom" app/Controllers/ScrapeController.phpRepository: fabiodalez-dev/Pinakes
Length of output: 17204
Discogs plugin cannot receive EAN/UPC inputs—the Discogs plugin does not register the scrape.isbn.validate hook.
The validation at lines 89–94 blocks any non-ISBN barcode (EAN/UPC) before scrape.fetch.custom runs. The Discogs plugin registers scrape.sources, scrape.fetch.custom, and scrape.data.modify, but not scrape.isbn.validate. Without this hook registration, barcode searches for music media never reach fetchFromDiscogs() because the request is rejected with a 400 error at line 94.
To support barcode-first lookups for CDs/LPs, add a scrape.isbn.validate hook registration in the Discogs plugin that returns true for valid EAN/UPC formats, allowing non-ISBN barcodes to bypass the ISBN-only validation gate.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/Controllers/ScrapeController.php` around lines 148 - 151, The Discogs
plugin needs to register the scrape.isbn.validate hook so barcode-only EAN/UPC
values bypass the ISBN-only gate; update the Discogs plugin to add a
scrape.isbn.validate handler that returns true for valid EAN/UPC formats (e.g.,
12/13-digit numeric barcodes) so requests pass the validation in
ScrapeController (which calls ensureTipoMedia and normalizeIsbnFields and
currently rejects non-ISBN barcodes before scrape.fetch.custom/fetchFromDiscogs
runs); implement the handler in the plugin's bootstrap/registration code and
ensure it only returns true for legitimate EAN/UPC patterns to avoid weakening
ISBN validation globally.
| <?php if (!empty($book['isbn13']) && !($isMusic && !empty($book['ean']))): ?> | ||
| <div class="meta-item"> | ||
| <div class="meta-label">ISBN-13</div> | ||
| <div class="meta-value"><?= htmlspecialchars($book['isbn13'], ENT_QUOTES, 'UTF-8') ?></div> |
There was a problem hiding this comment.
Music rows with only isbn13 still render the book label.
MediaLabels::label('isbn13', ...) already maps music identifiers to Barcode, but this branch hardcodes ISBN-13. If an import leaves the barcode in isbn13 while ean is empty, the detail page still shows the wrong label.
💡 Minimal fix
- <div class="meta-label">ISBN-13</div>
+ <div class="meta-label"><?= \App\Support\MediaLabels::label('isbn13', $book['formato'] ?? null, $book['tipo_media'] ?? null) ?></div>Also applies to: 1772-1775
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/Views/frontend/book-detail.php` around lines 1758 - 1761, Replace the
hardcoded "ISBN-13" meta-label with the dynamic label generator so music rows
that carry a barcode in isbn13 show the correct label: call
MediaLabels::label('isbn13', $book['isbn13']) (while still escaping the value
for output) instead of printing the literal "ISBN-13"; do the same change for
the repeated block that appears later (the similar block at the other
occurrence). Ensure you still render the escaped value via
htmlspecialchars($book['isbn13'], ENT_QUOTES, 'UTF-8') but use
MediaLabels::label('isbn13', $book['isbn13']) for the meta-label so music
identifiers map to "Barcode".
| <button type="button" | ||
| onclick="togglePasswordVisibility('api_token')" | ||
| class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"> | ||
| <i class="fas fa-eye" id="api_token_icon"></i> | ||
| </button> |
There was a problem hiding this comment.
Label the token visibility toggle.
This new icon-only button has no accessible name, so assistive tech just announces “button”. Add an aria-label and keep it in sync with the visible/hidden state.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@storage/plugins/discogs/views/settings.php` around lines 112 - 116, The token
visibility toggle button has no accessible name; update the button element used
with onclick="togglePasswordVisibility('api_token')" (the button containing <i
id="api_token_icon">) to include an aria-label (e.g., "Show API token" / "Hide
API token") and modify the togglePasswordVisibility function to update that
aria-label each time it flips state so the label always reflects the current
action and stays in sync with the icon state.
1. Discogs: register scrape.isbn.validate for EAN/UPC barcodes 2. mergeBookData: dynamic source from payload, not hardcoded 'discogs' 3. enrichWithDiscogsData: resolveTipoMedia (honors explicit tipo_media) 4. Authors array normalize (handle structured ['name' => '...']) 5. strip_tags: br→newline before stripping in tracklist rendering 6. PluginManager getPluginInstance: try/catch \Throwable → null 7. Updater: backup cleanup non-fatal after successful swap 8. Migration: AFTER formato guard (check column exists)
Security: - Cast releaseId to int (SSRF prevention) - Add CURLOPT_PROTOCOLS, CURLOPT_MAXREDIRS, CURLOPT_CONNECTTIMEOUT, CURLOPT_SSL_VERIFYPEER to all 4 curl call sites - Add curl_error() logging on CoverArt, MusicBrainz, Deezer calls Bug fixes: - inferTipoMedia: exact match for short tokens 'cd'/'lp' to prevent false positives on 'CD-ROM' and words containing 'lp' - formatTracklist: detect pre-formatted <ol> HTML and return as-is - Schema.org: separate CreativeWork branch (no isbn/numberOfPages) - Migration SQL: replace %cd%/%lp% LIKE with specific patterns Code quality: - Remove dead $discogsToken parameter from searchMusicBrainz() - Make Discogs rate limiter static (consistent with MusicBrainz) - Make Deezer rate limit elapsed-based (not fixed usleep) - Add missing translation key to en_US and de_DE Config: - Full .coderabbit.yaml with 9 path_instructions sections
|
@coderabbitai full review |
✅ Actions performedFull review triggered. |
- Optional plugins (discogs) now start inactive on first registration via metadata.optional flag in plugin.json + PluginManager check - Add aria-label/role=img on media type icons in DataTable (a11y) - Add aria-label on token visibility toggle button - Use MediaLabels::label() for ISBN-13 label in frontend (was hardcoded) - Fix test identifiers: EAN/ISBN now use RUN_ID to avoid collisions - Fix test teardown: soft-delete instead of hard DELETE - Translation fixes: Disco→Disc, Cartaceo→Print (en_US) - Add Mostra/nascondi token translation key (en_US, de_DE)
Summary
New optional plugin that integrates with the Discogs API for music metadata scraping (CD, LP, vinyl, cassette).
Closes #87
How it works
scrape.sources,scrape.fetch.custom,scrape.data.modify)Configuration
Files
storage/plugins/discogs/DiscogsPlugin.php— Main plugin classstorage/plugins/discogs/wrapper.php— PluginManager compatibility wrapperstorage/plugins/discogs/plugin.json— Plugin manifeststorage/plugins/discogs/README.md— Documentation.gitignore— Added discogs plugin exceptionsTest plan
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
UI Improvements
Localization
Tests
Chores